Author SHA1 Message Date
gitea-actions 6ab185ce50 chore(release): v2026.10.12.2 [skip ci] 2026-10-12 13:28:59 +00:00
James Mackay 400f0327af nMerge branch 'master' of https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield
Release / release (push) Successful in 8s
Test / test (push) Successful in 53s
Test / ha-api-drift (push) Failing after 3m22s
Test / real-ha-config-flow (push) Successful in 1m9s
2026-10-12 14:28:44 +01:00
James Mackay 7ed982801a test(adb-off audit): name the carve-outs, drive the paths the audit names (t25)
The t24 review's four observations on the t23 ADB-off audit. No production code
changes: button.py is untouched, deliberately (see O1).

O1 — THE PAIR ADB CARVE-OUT, ACCEPTED AND NAMED. The button's press builds its own
AdbMediaSessionPoller and connects without ever reading the entry's option. I
searched for a production route that reaches the press with ADB off and found
NONE, so no gate was added:
  * `button.async_setup_entry` is the ONLY place that entity is created, and it
    creates it only while the option is on (button.py:34-35);
  * every options save runs through `NvidiaShieldOptionsFlow(OptionsFlowWithReload)`,
    which reloads the entry and re-runs that setup — so turning ADB off takes the
    button away (config_flow.py:1340-1353);
  * the press is user-initiated in any case — the same class as the already
    declared `test_connect` pairing probe.
It is now a NAMED carve-out in the audit's `CARVE_OUTS` table, with what it does,
why that is safe, and the assertions behind both. The measurement is DRIVEN rather
than quoted: the press is executed with the option OFF and ON and asserted to cost
the same 1 ctor / 1 connect / 1 disconnect, so the carve-out's substance is a test
result and not prose. A meta test checks every carve-out's named assertion really
exists, so a carve-out cannot point at a phantom. MUTATION-CHECKED: making
`async_setup_entry` add the entity unconditionally fails three of the new tests.
The intent button is explicitly NOT a carve-out (it can exist for a disabled entry
and refuses through the t23 accessor gate) — stated in the same entry.

O2 — THE AUDIT NOW EXECUTES WHAT IT NAMES. `_open_logcat_stream` and the logcat
ladder are driven both ways: two new rows in the parametrized `AUDITED_PATHS` table
(so the disabled case costs zero traffic) plus dedicated assertions — the open
RAISES "ADB is switched off" in a `pytest.raises(match=...)`, and the ladder
RETURNS at once with `_logcat_failures == []` and `_logcat_disabled is False`, i.e.
a local option is not booked as a device failure. The anti-overcorrection pin is
kept in the same class: with ADB ON, five real ladder failures DO auto-disable.
MUTATION-CHECKED: removing either t23 logcat gate fails three of these tests.

O3 — THE ROTTABLE FIGURE IS GONE. The statement_blocks bullet no longer states a
count ("currently 28" was stale within one unit of work); the band (~2%) is the
policy, the current walk lives in the header the sweep prints, and the comment now
says why no band carries a count. The relative_imports bullet lost its illustrative
figure for the same reason.

O4 — THE ONE WRITE IS NAMED AS A DEBT. The audit's module docstring now states
exactly what "nothing is cached" means: the single write a disabled path can still
make is `_app_label_failed.add(package)` in `_dump_label` — a retry DEBT, not an
answer. It is pinned by a test: with ADB off the debt is recorded while
`_app_labels` stays EMPTY and the lookup still answers None; with ADB back on the
probe runs, the label is cached and the debt is settled (the discard that the code
already does).

FAIL-CLOSED, CONFIRMED AND LOCATED. The harness change (`entry=None` now reads as
ADB off) matches production because both read the option the same way: the manager
returns "disabled" for a missing option, and `__init__.py` builds no poller for the
same missing option — asserted by two new tests (an entry with `options={}`, and a
manager with no entry at all) and by a source assertion on `__init__.py`'s own
`if not entry.options.get(CONF_ADB_ENABLED, False):`. No production path is
affected: production always constructs the manager WITH the entry, and for an entry
whose option is on nothing changed at all.

No regressions: 942 passed / 5 skipped (V1), 943 / 4 (V2) — the +19 are the new
tests. ruff clean, drift exit 0, the real-HA pattern collects and runs its 3 files.
The device side remains a scripted double: the claim proven is that nothing was
sent, not what firmware would do.
2026-10-12 13:46:55 +01:00
James Mackay de1c34777d fix(adb): ADB-off is asked by every device path, not inferred from a missing poller (t23)
The t22 review's O1, and the same bug class as t21's F1 — so it gets the same
structural treatment instead of a note.

WHAT WAS MEASURED. With a poller injected into an entry whose own option said
`adb_enabled: False`, the installed-apps sweep sent 4 real shells (the reviewer;
3 on this test's double, which yields one launchable package) and a label probe
sent 1 more. Both gated on `self._poller is None` — and the poller's absence is a
property of the SETUP code (`_build_poller_factory` returns None when ADB is off),
which is precisely the indirect guarantee the t21 repair had already shown to be
insufficient. Production was unreachable only through that setup path.

THE FIX. `_adb_disabled()` is now asked directly by every path in the manager
that can reach the device, and `_adb_active()` (option ON *and* a poller) is the
shared predicate for the ones that also need a transport. The `available` property
deliberately keeps its transport meaning (diagnostics reads it as "ADB is
configured"); a new docstring says so, because overloading it is how the gate got
lost in the first place.

Gated paths, and what each asks now:
  * intent scan — the option (t21, unchanged) AND the private shell site itself
  * installed-apps sweep — the option first, then poller+connected
  * installed-apps refresh (public entry) — returns the last table, starts nothing
  * app-label lookup / drain / the ONE probe site (`_dump_label`) — the option; a
    lookup returns None and queues NOTHING (no request, no task, no cache write)
  * ADB text entry, media keys, bring-to-foreground, digit and enter keys — `_adb_active()`
  * the heartbeat `_refresh` — the option: no wake probe, no media dump, no
    running-apps poll, no foreground fallback, no label drain, no logcat ensure
  * the watchdog — the option BEFORE the poller factory, so not even a factory can
    build a transport; and `_watchdog_cycle` refuses a direct call too (a TCP
    connect is device traffic)
  * the logcat stream — the option at the ensure gate, the option in
    `_open_logcat_stream`, and the ladder exits WITHOUT counting a failure (a local
    option must not auto-disable the option for the session)
  * the public `adb_poller` accessor — None while the option is off, which closes
    the shell paths that live OUTSIDE this module (the `adb_command` service, the
    screen-capture image and the intent shell) without reaching into them: all
    three already refuse on None with their own message
  * `test_connect` (the pairing probe) stays ungated on purpose: it runs BEFORE an
    entry exists, and the setup menu only offers that step when the ADB option is
    on (config_flow.py). `async_stop`/`_note_failure` disconnect calls are teardown,
    not traffic.

PROOF, BY TRAFFIC. tests/test_adb_off_gates_every_device_path.py drives every
audited path — as a parametrized AUDIT table plus one full cycle on one manager —
with `adb_enabled: False` and a double that WOULD answer everything, and asserts
the poller's complete traffic list (shells, polls, commands, connects) is EMPTY,
plus that nothing was cached (no intent answer, no app table, no stamp, no queue,
no store field, no wake reading). It also proves the watchdog never builds a
poller and never connects, that a direct cycle does not connect either, that the
option is a CHECK and not a latch (the same manager resumes the sweep, the scan,
the label queue, the media key, the digit key and the accessor when the option
flips back on), and that ADB-off and no-poller stay told apart.

PRE-FIX PROOF: the same file against `git archive HEAD` FAILS 16 of 23 tests, and
the failures print the measured traffic — the sweep's 3 shells, the label probe's
`pm dump … | grep …`, `input text hello`, `media:play`, `foreground:<pkg>`,
`digit:5`, `enter`, and 4 polls per heartbeat cycle.

HARNESS TRUTHFULNESS. The suites' managers were built with `entry=None`, which
means ADB is OFF (fail closed, matching `__init__.py`). Those harnesses now pass a
realistic entry whose option says ADB is on — they model production instead of the
one combination the gates block. No production behaviour changed for an ADB-on
entry: 923 passed / 5 skipped, up from 900 by this file.

Floors re-derived from the new walk (1661 blocks → 1627, same 2% band): raised,
because that is what the re-measurement says. Drift exit 0. Strings files
byte-identical.

NOT VERIFIED HERE: the device side is a scripted double, so what is proven is that
NOTHING WAS SENT, not what firmware would do; no real Shield was involved.
2026-10-12 13:32:34 +01:00
James Mackay 8f064b3247 fix(intents): an ADB-off entry never scans, and a truncated detail is never believed (t21)
The t18 review's three findings, closed with the reviewer's own repro.

F1 (HIGH — the harm was real shells, not a wrong label). `async_scan_intents`
decided between `adb_disabled` and `adb_unavailable` INSIDE `if self._poller is
None:`, so a manager that HELD a poller fell through to the shell loop: with
`adb_enabled: False` and a connected poller the review measured status `fresh`,
one candidate CACHED, and `pm dump com.x …` really sent. The option is a fact
about the ENTRY, so it is now checked once at the top and returns before
anything else — no cache read, no cache write, no shell. The no-poller branch
keeps the OTHER honest answer: with the option ON and nobody to ask, the status
is `adb_unavailable` ("wait or reconnect"), never `adb_disabled` ("go turn the
option on"), because the two have different fixes. Five tests pin it, including
a cache-leak case (a manager with a cached answer whose option is then switched
off answers `adb_disabled`, not the cache) and both statuses with a FORCED scan.

F2 (low, but it fabricated a value that looked real). A capture cut mid-detail
produced `Scheme: "ht` → scheme `"ht` and `Authority: "www.exam` → that
authority, and `_candidate_data` would then prefill the form with a URL no app
can match. Not an injection (everything stays quoted when used), which is
exactly why nothing caught it. `_unquote` now DROPS a value that opens a quote
and never closes it (a lone quote too), so the callers turn it into None: the
candidate survives with its intact action/component and simply carries no
scheme/authority/path — and therefore prefills NO data instead of a broken URL.
A complete quoted value is still read (the anti-overcorrection pin), and the
consequence is pinned at the consumption end too, through the real options-flow
helper.

F3 (low). The floor comment now answers the review in writing, where the next
reader looks: the t18 review's measurement (awaited_calls 6 against a floor of 5,
three other counters within 2) is named, and the verdict is recorded — those ARE
the policy margins (2 for owner-counters because an owner really can be
refactored away, 1 for awaited_calls because six sites cannot carry a percentage
band). The counters were genuinely updated, not dodged: raised to the real sweep
when the code grew (20→24, 70→75, 235→275, 55→63, 1400→1620) and re-derived
twice since (t20 1620→1615, t21 1615→1617 for this unit's blocks).

EVIDENCE THAT THE TESTS PIN THE FIX (not merely pass): the same files were run
against the PRE-fix source (git archive HEAD, a throwaway copy under /tmp) — 9 of
the 11 new tests FAIL there, including the reviewer's repro, which fails with
`assert 'fresh' == 'adb_disabled'`. The two that pass are the deliberate
anti-overcorrection pins: a COMPLETE quoted value is still parsed, and the
no-poller control keeps its own status.

NO REGRESSION: 900 passed / 5 skipped, 901 / 4 with androidtvremote2; the
injection proof, the fixture's candidates with dedup/flagging/order/bound
disclosures, both cache outcomes, the registration round trip through the real
FlowManager, button lifecycle and defaults, the YAML seed rule and §7.1
(supports_response optional, a real return_response call, a no-response call) all
still pass. The strings files are byte-identical (untouched here). Real-HA pattern
collected and RAN 3 files → 74 passed / 0 skipped. Drift exit 0 (1650 blocks).

NOT VERIFIED HERE: the device side remains a scripted double — no real Shield was
asked for its resolver table, so the shell COUNT and the status vocabulary are
what is proven, not real firmware behaviour. No browser either: the form prefill
is asserted on the values the helper returns.
2026-10-12 13:14:34 +01:00
James Mackay 6b866019ef fix(transport): no stale protocol error, no connect task outliving its entry, honest banners (t20)
The t19 review's findings on the protocol transport, all of them closed here.

O1 (MEDIUM, FIRST). `_note_protocol_state(PROTOCOL_CONNECTED, None)` only assigned
`last_error` when its argument was non-None, so a failure kept being published
after the transport recovered: the review measured
`protocol_last_error == "connect: Net: boom"` beside `protocol_connected: true` —
a contradiction on the exact attributes the diagnostic surface was built for, so
the user could not answer "is the protocol working?" without a device test.

The pair now has ONE stated meaning, in the code that owns it and in the remote's
attribute docs: `protocol_state`/`protocol_connected` is the transport's CURRENT
condition, and `protocol_last_error` is the failure the current state has NOT
recovered from. A session becoming proven CLEARS it — and a session becomes proven
in BOTH places that can prove one, so the connect success and the transport's own
availability push now take the same route (the push previously assigned the state
directly and would have left the stale error again). `protocol_failures` keeps
counting, so a recovery stays visible as history rather than as a stale message.
A failure that happens AFTER the session came up is still kept: that one is
current, not stale, and a test pins that distinction so a future "just clear it
everywhere" refactor cannot make failures invisible.

O4 + O5 (the lifecycle leaks, now real tests instead of observations). The connect
attempt was never in the manager's task collection, so `async_stop` never cancelled
it — a reconnect loop could outlive its entry, and since every options save reloads
the entry those loops could accumulate across saves. `async_stop` also only
SCHEDULED its cancellations. The attempt is now tracked, `async_stop` cancels AND
awaits every tracked task, and the stop path refuses to start anything new. A
double start is deterministic instead of orphaning the first: a live attempt is
reused, an already-handed-over session (connected or disconnected — the library's
keep_reconnecting owns it) starts nothing, and only a FINISHED attempt that never
got a session may be replaced. The guard sits before the push callbacks are
registered, so a second start cannot double-register them either.

O2 (LOW). The connect banner claimed liveliness a successful `async_connect()`
cannot know. It now says exactly what it proved (the client accepted a session)
and hands liveness to what can actually report it (the transport's own
availability push). STALE is defined where the fixes live: a banner is stale when a
later state change never followed it up, so the log's last word contradicts the
entity. Every transition therefore announces itself with both states — INFO for
the two a user acts on, DEBUG otherwise — which is what makes the last transport
line always match the published state.

O3 (LOW). The drift floors now carry an explicit MARGIN POLICY instead of leaving
a 2-margin unexplained: a floor is a vacuity-and-blindness alarm, not a size
ratchet (it must fail when the walk stops COVERING something, not when a
legitimate change removes code). Owner-counters keep a 2 margin (the project's own
t62 canary, now stated once), statement_blocks moves to a ~2% band (28 blocks —
the t19 complaint was that a 23-statement refactor could trip it), relative_imports
~2.5%, and the awaited-calls floor is called out plainly as the weakest one because
a percentage margin on six sites is meaningless. The rule for changing any floor is
written down with it. The walk is now 1648 blocks, so the floor is re-derived:
1615.

EVIDENCE THAT THE TESTS PIN THE FIX (not merely pass): the same test file was run
against the PRE-fix source (`git archive HEAD`, a throwaway copy under /tmp) and 13
of the 15 new tests FAIL there. The two that pass pre-fix are the deliberate
anti-overcorrection pins: a failure that is still current keeps its message, and a
terminal (auth-rejected) attempt may still be replaced.

No regressions: 889 passed / 5 skipped, 890 / 4 with androidtvremote2, the real-HA
pattern (3 files) 74 passed with no skips, ruff clean, drift exit 0.

NOT VERIFIED HERE: no real Shield and no paired device, so no real TLS session,
certificate or push was ever received — the connect path runs against doubles plus
the installed library's real source, and the *timing* of a cancellation during a
live handshake is inferred from the awaited-cancel path, not observed on hardware.
2026-10-12 13:08:54 +01:00
James Mackay 66846b2f82 test(intents): the button's uninstalled-app press, with the device's own words (t17)
Acceptance item 3 named this case explicitly: a registered intent whose app is
gone must fail the press HONESTLY. The device's sentence is what the error
carries ('does not exist', naming the component), the press is attempted exactly
once, and it raises rather than doing nothing.
2026-10-12 13:00:24 +01:00
James Mackay e6c6bdb1e6 test(intents): the real-HA action proof, the README pins and the raised drift floor (t17, part 3)
The evidence half of the ticket. Two things here are new facts about the
installed Home Assistant rather than about our code.

THE ACTION, DRIVEN BY REAL HOME ASSISTANT (tests/test_send_intent_real_ha.py).
The claim "the action returns the device's output" cannot be settled with a stub:
whether a service ANSWERS is HA's decision. On 2026.10.1, with the real service
registry and the real websocket command the frontend calls:

* `supports_response` is OPTIONAL, which is what keeps every caller that does not
  ask for a response working — REQUIRED would break them;
* `get_services` serves the intent field as a dropdown whose options are the
  registered names AND keeps `custom_value` on, so a name typed by hand is still
  accepted;
* the entity target is `{entity: [{domain: [media_player]}]}` — the picker, the
  same mechanism t14 proved for adb_command (async_register has no target
  parameter; the runtime description is what the UI reads);
* a real `async_call(..., return_response=True)` comes back as
  `{"intent_response": "Starting: Intent { … }"}`, and the command that reached the
  ADB surface is asserted VERBATIM, including the `--es`/`--ez` flags and the
  resolved URL;
* an unknown intent, a missing required field, a refused intent (the device's own
  "does not exist" text is kept and the call RAISES), ADB being down and a missing
  or unsupported target each raise ServiceValidationError with our translation key
  and a message a user can act on.

The dropdown is published from `hass.data[DOMAIN]` — the store a real entry setup
fills — so the file also pins the EMPTY state: nothing stored means no options, not
invented ones.

THE OPTIONS SCREEN, DRIVEN BY THE REAL FLOW MANAGER
(tests/test_config_flow_real_ha.py). The Intents menu, the Add-new sentinel as a
real selector option, a registration that round-trips to `create_entry` (JSON-safe,
as the options store requires), a refused row coming back as a real form error with
its note, and a removal that only takes effect after the confirmation. This is the
file that exists because a step id which does not match its method name is an
UnknownStep that only a real FlowManager raises.

THE FLOOR (tests/ha_api_drift.py). The sweep now walks 26 modules, 77 imports, 282
relative imports, 65 call arguments and 1637 statement blocks (was 76/242/57/1417),
so the non-vacuity floors move with it: imports 70→75, relative_imports 235→275,
call_args 55→63, modules 20→24, statement_blocks 1400→1620. They stay just under
the walk on purpose — a floor's job is to notice a walk that silently stopped
covering the new surfaces, and the unreachable-statement pass is exactly the gate
that must keep covering the ~240 new blocks. Raised, never lowered.

THE README (tests/test_readme.py). The intents example is parsed and its keys are
required to be ones the seed honours (documented-but-ignored is the failure this
catches, t30's rule applied to the nested block), the screen and the action are
named, and every honest failure the README promises has its key. Plus a cross-check
in the intents suite: the row keys the README documents are exactly the keys the
seed validator KEEPS, so the docs cannot describe a key that silently vanishes.

Verify (all five, in the contract order):
  uvx --with pytest-asyncio pytest tests -q                     → 872 passed, 5 skipped
  uvx --with pytest-asyncio --with androidtvremote2 pytest tests -q → 873 passed, 4 skipped
  uvx --python 3.14 --with 'homeassistant>=2026.10' --with adb-shell \
      --with pytest-asyncio pytest tests/test_config_flow_real_ha.py -q → 49 passed
  uvx ruff check custom_components tests                        → clean
  uvx --python 3.14 --with 'homeassistant>=2026.10' --with pyyaml \
      python tests/ha_api_drift.py                              → exit 0, no drift

The real-HA pattern collected and RAN three files (no skips):
tests/test_adb_command_real_ha.py, tests/test_config_flow_real_ha.py,
tests/test_send_intent_real_ha.py → 74 passed.
2026-10-12 12:59:08 +01:00
James Mackay 259a6c29bd feat(intents): the Intents options screen, the seed, the strings and the tests (t17, part 2)
Completes the intents feature: part 1 shipped the engine, the buttons and the
action; this is the surface a user actually touches, plus the evidence.

THE SCREEN (config_flow.py). A new `intents` row on the Configure menu opens the
same editor shape as the Apps table, because HA has no table selector: a menu of
row actions, one row per step. Add and edit share ONE form, reached through a
select of the registered intents whose last option is an explicit "Add a new
intent" sentinel — so adding is a choice the user can see, not an empty-string
convention. The per-intent form is name, package, component OR action,
categories, a data template and the declared variables as text
(`key:type[:required][=value]`, blank lines free, `#` comments). Removal asks for
confirmation and takes nothing until the user confirms. "Scan an app's intents"
asks for ONE package (a dropdown of discovered apps with free text), runs the
device-side scan, and offers the candidates; each candidate is marked when it
looks like an internal hook, and a candidate whose path is a PATTERN prefills no
URL with a note saying why, because a pattern is not a link. A scan that cannot
run reports t16's status vocabulary in words a user can act on — ADB off, ADB
down, or the app really advertising nothing are three different sentences.

Nothing half-stored: a submission that fails writes NOTHING and the form comes
back naming each problem (a component without its slash, a package with a space,
a duplicate name, a data template using an undeclared placeholder, a fields line
that does not parse), and a successful edit REPLACES the row where it already is
instead of moving it to the end of the list under the user's eyes.

The screen's reader of `registered_intents` is a second implementation of the
runtime's one — deliberately, because config_flow must stay importable without
Home Assistant's media_player platform. A test drives BOTH over the same battery
of rows (valid, junk, duplicate id, mapping-shaped blob, over-long list) and
requires identical results, so the two cannot drift into meaning different
things.

THE SEED. `registered_intents:` in `configuration.yaml` is validated by the SAME
validator the form uses, so a seeded intent is identical to a typed one; a row it
refuses is dropped with a warning NAMING the row and the reason (a seed is
configuration.yaml: there is no form to show an error on, and a silent skip would
be indistinguishable from a typo). It is honoured only while the UI holds nothing
— at creation, because the Intents screen never writes a seed back and the import
step aborts for a known host first.

THE STRINGS (both files, byte-identical) carry every new step, field, menu row
and the action's own description, plus the exceptions the action raises. The two
shared target errors (`unsupported_target`, `ambiguous_target`) now interpolate
`{service}`, because `send_intent` reuses them verbatim and "for this action" was
`adb_command`-specific copy.

THE TESTS (+72 in tests/test_intents_registration.py, and repaired coverage in
test_command_routing.py). The command: the exact `am start` line per declared
type, a variable containing `"; rm -rf /"` reaching the device as ONE quoted
argument (asserted through shlex, not by eyeballing), a value that looks like a
placeholder being data rather than a second substitution, `{id.__class__}` left
alone because there is no format()/eval() path at all, and each refusal naming
the field. The scan: the status sentences, the candidate prefill, the pattern
case, an internal-looking candidate still being offered. The seed: warned drops,
and the seeded row being byte-identical to the form's. The buttons: one entity
per registration, its identity keyed by the intent's id so a rename does not
orphan it, defaults sent, and a press that cannot reach the Shield RAISING
instead of doing nothing.

test_command_routing's registry test used to hand-write the dict and set the OLD
attribute name, which is exactly why it never noticed that the add hook registered
`None` for a real entry and that the remove hook dropped nothing. It now drives
the real hooks, and pins both registries plus the ruling that registered intents do
NOT require the adb_command opt-in (a raw shell door and a registered intent are
different consents).

Verify: 868 passed / 4 skipped (was 794+4 before this ticket's tests — the new
tests are additive), ruff clean, both strings files byte-identical.
2026-10-12 12:54:41 +01:00
James Mackay a8041e1428 feat(intents): the registered-intent engine, the buttons and the send_intent action (t17, part 1)
Second half of the intents feature (docs/INTENTS_PLAN.md §3-§5), engine first: the
options screen that WRITES a registration follows in this same ticket.

WHAT SHIPPED.

* THE MODEL (const.py + media_player.py). A registered intent is ordinary options
  data — a list of dicts {id,name,package,activity,action,categories,data,fields}
  — so it survives a reload and is otherwise inert. `normalise_intent` accepts a
  row only if it names something to launch (a component with the slash Android
  requires, OR an action), `registered_intents` reads the options defensively
  (junk rows dropped, duplicate ids keep the first, the registration cap applied)
  and never touches a shell.
* THE COMMAND. `intent_am_start_command` assembles
  `am start -n <component> -a <action> -c <category>… -d <data> --es/--ei/--ez key value`.
  `{placeholder}` substitution is plain regex replacement over the DATA template
  only — no eval, no format(), no shell interpolation — and every value is
  shlex.quote'd, so a variable whose value is `"; rm -rf /"` reaches the device as
  literal data inside one quoted word. Address validation for adb_command is
  untouched; `adb_command` stays the one raw-shell door.
* HONEST FAILURES, each with a plain message AND a translation key so the button
  and the action both read well: an unknown intent name lists the registered ones,
  a missing REQUIRED field names the field, a variable the intent did not declare
  is REFUSED (nothing silently travels to the device and nothing is silently
  dropped), a value that cannot become its declared type names the field and the
  value, a data template with an unfilled placeholder names the placeholder, ADB
  off/down says the protocol cannot carry intents, a transport that never answered
  says so, and a device that refused the intent surfaces the DEVICE'S OWN message
  ("unable to resolve Intent", "does not exist", "Permission Denial") and raises.
* THE ACTION. `nvidia_shield.send_intent` takes `intent` (a select selector whose
  options are the loaded intents, `custom_value` on) plus `variables`, resolves
  exactly ONE Shield through t14's resolver (one participant, or no_target /
  unsupported_target / ambiguous_target naming the service), and returns the
  device's output under `intent_response`. Registered with
  `SupportsResponse.OPTIONAL` FIRST, because `async_set_service_schema` reads
  `hass.services.supports_response()` to publish the response flag — the t14
  mechanism, re-verified against the installed HA for t17. The description is
  republished on every entry setup, so an options change (which reloads) refreshes
  the dropdown.
* THE BUTTONS. One button per registered intent (name from the registration, id
  from the intent's stable id so a rename does not orphan the entity), pressing it
  sends the intent with the fields' declared defaults and raises on every failure
  instead of doing nothing. Its extra attributes expose what it will send.

TWO MEASURED REPAIRS IN THE SAME HOOK (media_player.py). Both were latent because
the test harness pre-sets `_manager`:
* the service registries were filled BEFORE the entry's manager was assigned, so a
  real entry registered `None` and `adb_command` would crash with AttributeError
  instead of answering. The manager is now consumed first, and both registries are
  filled after it;
* `async_will_remove_from_hass` never dropped the entity's participation (the
  comment said it did), so a removed or unloaded entry left a stale manager and the
  action answered `adb_unavailable` for an entity that no longer exists instead of
  reporting an unsupported target. Both registries are dropped now, keyed by the
  key computed at add time.

Verify: 794 passed / 4 skipped and 795 passed / 3 skipped (androidtvremote2) —
unchanged counts, so nothing regressed; ruff clean.
2026-10-12 12:43:10 +01:00
James Mackay 2819ee5a49 fix(media_player,config_flow): close the batch review's O2 and O3 (t17)
Both observations from the t8-t12 batch review, settled by execution rather than
by argument.

O2 — WHICH STATES MAY CARRY A NOW-PLAYING ROW. The review read
`extra_state_attributes` and argued the row publishes the entity state verbatim,
so `now_playing_state: on` is reachable, while the README documents only
playing/paused/buffering/idle; t10's author had reported that `on` and `off`
publish no row at all. The probe settles both halves:

* the t10 account is true on the NATURAL ladder — `on` means no source reported a
  power or playback fact, and every tier that can produce a reading sets
  `reported = True` in the ladder, so the two cannot meet;
* the review is right about the RULE. It was "publish unless the state is off", an
  assumption nothing enforced: forcing the ladder to `on` (and to `standby`, which
  the README does not even mention) published `now_playing_state: on`/`standby`
  beside a real title.

So the rule is now explicit — `NOW_PLAYING_STATE_VALUES = ("playing", "paused",
"buffering", "idle")` — and a reading is dropped whole when the entity state is
anything else, exactly as `off` always dropped it. Nothing legitimate is lost: a
tier only ever answers in one of those four (`idle` for "reported, nothing
playing", which is where the foreground tier's remembered-playback bridge lands).
The README says the same thing in the table row and in prose, and
tests/test_now_playing_publication.py walks EVERY `MediaPlayerState` member with a
reading present (the four publish themselves; on/off/standby publish nothing),
then reads the four values back OUT OF THE README and compares them to the
constant, so code and docs cannot drift again.

O3 — A REJECTED APP PASTE NAMES ITS BAD LINES. The bulk paste was all-or-nothing
and reported a generic `invalid_app_map`, so a 30-row paste with one typo had to
be bisected by hand. `_app_map_parse` now returns `(map | None, problems)` where
each problem carries the 1-based line NUMBER, the line as the user typed it, and
why (empty/space-bearing package, empty name, space in the icon), and the
rejection puts them in the form's `description_placeholders["invalid_lines"]`,
which the error copy interpolates — capped at 4 named lines with the remaining
count, so the message stays readable. The map is still None when anything is
wrong (never half-stored) and the detail is cleared as soon as a paste parses.
The single-row editor is untouched and keeps its own `invalid_app_row`; a test
asserts the new per-line report and `_valid_app_row` accept and refuse the same
rows, so a paste and a typed row cannot disagree.

Verify: 794 passed / 4 skipped, 795 passed / 3 skipped (with androidtvremote2),
ruff clean.
2026-10-12 12:36:15 +01:00
James Mackay 91d9fb3883 feat(intents): discover an app's intents from its activity resolver table (t16)
First half of the approved intents feature (docs/INTENTS_PLAN.md §2): the scan
that answers "what can this app actually be asked to do". No UI is wired here —
this is the discovery module, the manager path it rides on, and the tests.

THE DUMP IS FILTERED ON THE DEVICE. On the user's Shield `pm dump
com.netflix.ninja` is ~2241 lines of whole dumpsys for ONE app, so shipping it
over ADB to read a few dozen resolver lines repeats the mistake `| head -300`
made for app labels (t66 — where a truncation artefact was read as "this app has
no label"). The fetch now sends:

    pm dump <pkg> 2>/dev/null | sed -n "/Activity Resolver Table/,/Service Resolver Table/p" | head -n 400

with a bounded `grep -A 400 -m 1 -F 'Activity Resolver Table:'` fallback for a
device whose toolbox cannot do sed ranges, tried only when the primary answer did
not contain the section. The package id is the only caller-supplied text in
either command and is always shlex.quote'd, and the sed script is built from two
constants — tests drive eight hostile package ids (semicolons, &&, backticks,
$(), pipes, both quote kinds, a newline) and assert that the shell sees the
package as exactly one verbatim token and that no extra command token appears.

WHAT A CANDIDATE CARRIES: component, action, categories and the data
(scheme/authority/path, plus the matcher kind — `/title.*` is a GLOB, not a
path, and a caller that treats it as literal builds the wrong URI). A filter
carrying several schemes/authorities/paths expands to one candidate per
combination, because each is a concrete match, and dedup collapses the
coincidences. MIME keys are deliberately NOT modelled (they are not one of §3's
fields) — such a filter is still reported through its component, action and
categories rather than being invented into a URI.

RANK, NEVER HIDE. The four families the plan names as internal-looking noise
(DEBUG_*, androidx.*, com.google.firebase.*, ConstraintProxy*) are FLAGGED, not
deleted: they sort last so the useful rows come first, and the user still
decides. Ordering is total (flag, action, scheme, authority, path, component,
categories) so a re-scan is byte-identical, and the 200-candidate cap is applied
after dedup and DISCLOSED through truncated/found — a capped answer never looks
complete.

THE CACHE KEEPS AN ANSWER AND A FAILURE APART, structurally: put() is its only
writer, only a scan that RAN calls it, and it derives the entry's state — an
empty candidate list is INTENT_ENTRY_EMPTY, a real ANSWER the device gave, while
a scan whose transport failed caches nothing and is retried. A stale entry is
dropped on read rather than served. The scan itself has one status per outcome
(fresh / fresh_empty / cached / adb_disabled / adb_unavailable / unreadable /
invalid_package) so "ADB is off", "the device did not answer" and "this app
advertises nothing" can never be confused, and the not-answered statuses carry no
candidates at all — an ADB-down scan cannot look like an app with no intents.
ADB disabled and ADB unreachable are told apart from the entry's own option.
Discovery is EXPLICIT and single: one package per request, never a sweep over the
installed apps, and a test drives a watchdog cycle and asserts it starts no scan.
Concurrent requests for the same package share one dump.

THE FIXTURE IS A TRANSCRIPTION, NOT IMAGINATION. tests/fixtures/
pm_dump_netflix_resolver.txt reproduces the user's 2026-10-12 capture structure
(whole-dumpsys envelope, the activity table with its Schemes/Non-Data Actions
groups, the receiver/service/provider tables that bound it) and the test file's
docstring states line by line what is VERBATIM from the ticket/plan and what is
reconstructed — no real Shield was available, and the raw capture is not in this
repository (the same limit t13 recorded). The capture's own entries are each
pinned by an assertion: the cast entry, http/https with the four categories and
the five www.netflix.com path globs, nflx:, netflix:, MAIN with LEANBACK_LAUNCHER
+ LAUNCHER, NETFLIX_KEY_START, DIAL_START and the two DEBUG_* noise actions —
18 candidates, deduplicated, with the receiver/provider tables proven unread.

Evidence: 59 new tests in tests/test_intents.py (parser against the transcribed
capture, degradation on shapes never seen, the command shape and quoting safety,
the cache states and TTL, and the manager: one shell, cached, retried on failure,
fallback recovery, ADB-off versus ADB-down, the cap disclosed, the watchdog
silent, concurrent sharing). No device was available: the parser is proven
against the transcription, and every "runs/does not run" claim is an assertion on
the poller's recorded commands rather than on intentions.

Verify: suites 778/4 and 779/3; ruff clean; drift exit 0 (26 modules, 76 imports,
242 relative imports, 1417 statement blocks, 57 call arguments) with the
relative-import floor raised 195 -> 235 and the statement-block floor 1300 ->
1400 by the new module rather than dodged; the real-HA glob still runs green
(56 passed, 0 skipped).
2026-10-12 12:27:01 +01:00
James Mackay 14c168c25e feat(remote): the app/activity picker, and a protocol transport that really connects (t15)
The user's remote displayed but offered no app picker, and the protocol
transport was never actually switched on. Both halves are fixed here.

REMOTE PARITY. The entity declares RemoteEntityFeature.ACTIVITY — exactly
what core's androidtv_remote declares — and publishes activity_list, which
is what makes the frontend render an activity picker at all. The list is
the app directory (the user's apps map, else the curated table as a floor)
in map order, then the apps ADB discovery found and something can name, in
the one name chain (user map > device label > curated > package id).
Nothing nameless is listed as a bare package id, and current_activity is
None until something is known: no activity is invented. remote.turn_on
honours activity= by waking the device when it reports itself off and then
launching through the existing launch path; a listed name resolves
case-insensitively, a package id or a detail link passes through, and
anything else raises unknown_activity naming the value instead of silently
doing nothing. An empty/absent activity keeps the previous behaviour
exactly, async_turn_off is untouched, and a configured command script still
owns the sequence.

WHY THE TRANSPORT WAS DEAD, MEASURED. androidtvremote2's keep_reconnecting
only starts _async_reconnect, whose whole body is
`while self._remote_message_protocol:` — false on the first check with
nothing connected, so the task returned immediately: no connect attempt, no
retry, and the auth callback was never reachable. HA core connects FIRST and
only then keeps it alive. Our _start_protocol called keep_reconnecting after
generating a certificate and never called async_connect anywhere, so every
send_key_command/send_launch_app_command raised ConnectionClosed internally,
the helpers swallowed it into `return False` plus a debug line, and no push
(is_available/is_on/current_app/volume_info) ever arrived.

The fix is a background connect loop: async_connect under a bounded timeout,
network failures retried with the ADB watchdog's jitter and backoff,
InvalidAuth routed into the EXISTING reauth callback (entry.async_start_reauth),
and keep_reconnecting only after a session is up. Setup never blocks on a
transport. A send that cannot run is no longer silent: it is counted, its
error recorded verbatim, and logged at WARNING with the transport state, and
the remote publishes protocol_state / protocol_connected /
protocol_ever_authenticated / protocol_last_error / protocol_failures /
protocol_certificate plus sticky per-push *_seen flags — so "sent" and
"never switched on" can never look alike again.

CASE (b) IS FIXED, NOT DOCUMENTED: switching the transport on in the options
flow for an entry that never paired now routes into the real
async_step_protocol_pair, and "continue without pairing" persists the
transport OFF. One marker carries the pairing (CONF_PROTOCOL_PAIRED),
written by the setup pairing step, the reauth finisher and the reconfigure
finisher, so no entry can sit enabled-but-unpaired. The certificate from the
first pairing is reused by design, so a paired entry is asked for no new PIN
— which is also why the marker, not certificate material, is the source of
truth.

ADB STAYS THE PRIMARY STATE SOURCE. Arming the protocol introduces pushes
that were never live before, so the reading now names its tier:
manager.foreground_source drives the media player's app_source and the
remote's activity_source, and the new tests prove a full protocol push set
leaves the media state, the now-playing reading and every ADB store field
untouched, while an ADB-only entry still serves media player, sensors, apps,
scripts and receiver with only the protocol surface degraded.

Copy: unknown_activity, activity_launch_failed and protocol_unavailable in
BOTH strings.json and translations/en.json, pinned by a test; the README
documents the picker, activity resolution, how to read the transport-state
attributes, the script bypass (a script-routed press is never evidence of
pairing), the connect-then-keep-alive lifecycle and cert reuse versus the
other integration's own pairing.

Evidence: 6 new real-HA FlowManager tests in tests/test_config_flow_real_ha.py
(unpaired -> pairs, PIN -> marker, skip -> off, paired -> not asked again, and
the setup flow's marker on success and on skip), a real-HA entity contract
test in tests/test_ha_api_drift.py (exact supported_features equality with
core, activity_list/current_activity reaching the state machine, live _attr_
updates) and new suites in tests/test_protocol_transport.py (connect-first,
keep_reconnecting alone is a no-op, retry, auth rejection, honest send
failures, ADB-primary precedence) and tests/test_command_routing.py (the
activity contract, turn_on(activity=), script ownership, the transport
attribute states). No device and no browser exist here: device behaviour is
inferred from the library and core source, and every UI-facing claim is
proven at the entity/registry/metadata level under real Home Assistant.

Verify: suites 719/4 and 720/3; real-HA glob (tests/test_adb_command_real_ha.py
+ tests/test_config_flow_real_ha.py) 56 passed, 0 skipped; ruff clean; drift
exit 0 (25 modules, 76 imports, 206 relative imports, 1322 statement blocks,
57 call arguments) with the floors raised 40->70, 100->195, 500->1300 and
52->55.
2026-10-12 12:15:58 +01:00
James Mackay 6aa3b6bfdd feat(adb_command): selectable target, strict resolution, and a real response (t14)
The user's live run could not select his Shield for
nvidia_shield.adb_command, so he ran his diagnostics through the deprecated
androidtv integration instead. Measured against the installed HA (2026.10.1),
the cause is a contract, not a bug in the handler:

* ServiceRegistry.async_register has NO target parameter, so a target cannot be
  declared at registration;
* the frontend renders the target picker from the service DESCRIPTION, which
  HA builds from services.yaml or from the runtime async_set_service_schema —
  and the runtime description WINS.

So async_setup now registers with supports_response=OPTIONAL and publishes
ADB_COMMAND_SERVICE_DESCRIPTION (target: {entity: [{domain: media_player}]},
fields, prose mirrored in strings.json/translations/en.json and pinned against
both by a test). No services.yaml is needed, and none is added.

The target resolution is strict, because the failure mode of guessing is a raw
shell command on the wrong device: exactly one PARTICIPANT, told apart as
no_target (nothing selected), unsupported_target (not a participant / option
off / another integration's entity) and ambiguous_target (two selected — sent
to neither). Accepts what HA actually sends: entity_id as text or as the LIST a
picked target produces, and device_id (an automation targeting the DEVICE),
expanded only through this integration's own registry entries and only to media
players. The output now comes back in the response under adb_response — the key
the user's captures read — while an unreachable Shield stays adb_unavailable,
an error rather than an empty response, and a call that does not ask for a
response is unchanged.

Evidence: 12 real-HA tests (new tests/test_adb_command_real_ha.py) drive the
real service registry, the real get_services websocket payload (the exact JSON
bytes the browser parses: target present, response optional, both fields) and
real async_call for every shape above. t14's CI gate globs tests/test_*real_ha.py
so a new real-HA file cannot skip silently; the skip/empty-collection guards stay
and were executed. The real-HA probe also caught async_entries_for_device being
a MODULE-LEVEL helper (not a registry method) that the stub had hidden, so the
drift gate's owner map now covers async_register, async_set_service_schema and
async_entries_for_device, with the call-argument floor raised 44 -> 52.

Verify: suites 681/4 and 682/3; real-HA 38 + the new file 12 (50 with the glob);
ruff clean; drift exit 0 (54 call arguments).
2026-10-12 11:23:47 +01:00
James Mackay 8a4e72b80a docs(intents): plan the intent catalogue and registered actions 2026-10-12 10:55:32 +01:00
James Mackay 7b32de789a t66: app names on a device that exposes none
The reported Shield names no app: `pm dump com.netflix.ninja` is ~2241 lines of
dumpsys and grep for `application-label` over the whole capture finds nothing.
The probe shipped `| head -300`, which cut the dump BEFORE the package section,
so "no label" was a truncation artefact; the sweep then named every app after
its own package id, and the apps editor pre-filled the `pkg=pkg` row the user
read as a bug.

  * the probe greps ON THE DEVICE for the three shapes Android prints
    (`application-label`, `nonLocalizedLabel`, `labelRes`); a quoted value is a
    name, `null` and a resource id are not. The dump is never truncated.
  * after APP_LABEL_PROBE_LIMIT consecutive "ran, no label" answers the device
    is taken at its word: the sweep stops spending a shell per package
    (APP_LABEL_TTL expiry re-checks, and a package whose probe FAILED still owes
    its retry — t57 UNIT 0 survives, driven by the app on screen).
  * ONE precedence chain in `ha_adb_common/app_label.resolve_package_name`, used
    by the media player, the Current App sensor, the discovery table, the source
    list and the apps editor: user map > device label > curated map > package id
    (the id shown ONCE, `name` empty when nothing names the app).
  * the curated table covers the user's 21 apps (community-maintained, not
    device truth) and the Configure row says when the device named nothing.
  * the real device shape is a test fixture (trimmed, and marked as such in the
    file: the raw capture is not in the repo) pinning that no label is
    extracted, that the probe count is bounded, and that `Netflix` surfaces.

Also drops the dead `ATTR_MEDIA_DAG_NOW_PLAYING` re-exports left by the t65
flattening and updates the RESEARCH_SHIELD now-playing note to the flat names.

No real Shield was available: the fetch/burst behaviour is exercised against the
documented Android output and the trimmed reproduction of the reported capture.
2026-10-12 10:54:38 +01:00
James Mackay f29f62ddae feat(image): opt-in screen capture as an image entity, binary-safe over ADB
The user asked for "an image entity of the device screen capture? Can that be
added". It is added as `image.<entry>_screen_capture`, and the three boundaries
are the feature:

- it NEVER drives state — the state is the capture timestamp, and the capture
  path reads the ADB poller and nothing else (pinned by a manager that raises on
  any other attribute, and by a real-HA run whose recorded state is the
  timestamp with no bytes anywhere in the attributes);
- it is OPT-IN and OFF by default (`screen_capture_enabled`, Main setup row) —
  with the option absent `async_setup_entry` adds no entity at all;
- it NEVER polls — HA's own `should_poll = False` for image entities is kept, so
  only an image fetch or `homeassistant.update_entity` captures; concurrent
  fetches share one shell and a capture is reused for 5 s.

BINARY-SAFE CAPTURE: `screencap -p | base64` over the existing shell, decoded
locally with `base64.b64decode` and gated on the PNG signature AND the IHDR
chunk. `AdbDevice.shell()` returns `str`, so raw PNG bytes cannot travel through
it; the alternative (a device temp file + the library's file-read path) needs the
raw AdbDevice and writes to the Shield's shared storage, where a crash between
write and delete leaves a screenshot behind. The base64 route leaves no
device-side artifact — nothing to clean up — at the cost of ~33% more bytes on
the wire.

HONEST FAILURES: ADB off / ADB down / a capture that returned no PNG / a
transport that raised all make the entity UNAVAILABLE with `capture_status`
(`adb_disabled` / `adb_disconnected` / `capture_failed`) — never a blank or
zero-byte image. `image_last_updated` moves only on success.

Tests: 25 new cases (a real 2x1 PNG fixture on the wire — bytes, never a str;
every non-PNG answer rejected; disconnected/failed/recovery paths; capture reuse,
coalescing and the expiry window; opt-in default OFF and on) plus 4 config-flow
cases (schema default False, off stays off, on is stored, and it can be turned
back off). The stub harness gains the image platform and `util.dt` faces, the
real-HA entity matrix gains this class, and the drift sweep's coverage test
demanded exactly that. README documents the cost, the opt-in and the boundary.
2026-10-12 10:28:32 +01:00
James Mackay a1ce28996e feat(media-player): flatten now-playing into separate attributes and name the tier
The user reads attributes as YAML — "These could do with being separate
attributes rather than an object in one?" — so the nested
`media_dag_now_playing` object is gone and every field it carried is its own
top-level attribute:

  now_playing_via, now_playing_state, now_playing_title, now_playing_artist,
  now_playing_album, now_playing_media_type, now_playing_series,
  now_playing_season, now_playing_episode, now_playing_duration,
  now_playing_position, now_playing_position_updated_at, now_playing_thumbnail,
  now_playing_media_info

Rules the surface now holds to:

- an unknown field is OMITTED, never published as None (`title: null` inside the
  object was exactly the noise this removes); 0 and False are real values
- a device reading `off` publishes no now-playing row at all — a Jellyfin session
  that has gone stale must not put a title beside a device that is off — and `on`
  (nothing reported) publishes none either, because there is no reading
- `now_playing_state` IS the entity's own state, so the ladder stays the single
  answer even while the foreground tier's last-known bridge serves a title
- the tier indicator is `now_playing_via` (`jellyfin` / `media_session` / `app`),
  NOT `source`: on a media_player `source`/`source_list` mean the selected app
  input (`Netflix`, `TiviMate`, …) and they still do — a tier named `source` is
  what made the reading look like media metadata from somewhere else. The internal
  reading key is renamed `via` as well, and the Jellyfin tier now names itself too
- every published name is declared unrecorded (G12): these change every poll

COMPATIBILITY: `media_dag_now_playing` is removed, not deprecated, and no alias is
kept — a nested copy would double every poll's payload. README documents the exact
names, these rules, the sensor's own flat set and a one-line template migration.

sensor.py: the Jellyfin now-playing sensor follows the same no-None rule (`title`
and `media_type` used to be published as null there).

Tests: 17 new cases pin the flattening, the tier rename, the five states, the
off/on gate, the playing->idle transition, the last-known bridge, privacy mode and
agreement with the Current App sensor; the tier-dict tests follow `via`.
2026-10-12 10:13:27 +01:00
James Mackay 46738063aa fix(options): edit apps one row per step in a multiline field, and report discovery
The live Apps page showed the whole map as ONE long line with nowhere to add a
row. Cause: the field was a bare `vol.Optional(...): str`, and HA renders a
selector-less string as a SINGLE-LINE input (pre-fix wire field:
{"type": "string", "name": "app_name_map"} — no selector key). It is a
multiline TextSelector now: the real-HA test pins
selector.text.multiline on the wire and an N-line save/reopen round trip.

HA has no table selector, so the Apps sub-screen is a menu of row actions
(whole list as text / add / edit / remove) instead of one form:

- the EDIT row lists every app the user can act on: the rows they saved AND the
  apps discovery found, marked `[detected]`, each labelled "name (package id)"
  or "package id (no name yet)" — the user's Shield exposes no device labels at
  all, so the unnamed case is the common one and it must not read as its own id
  twice, and picking it is how a name gets set (saving stores the row)
- add: package + name + optional icon, refused by the SAME `_valid_app_row`
  rule the text field uses, with an error on the page
- edit: PREFILLED from the stored row (suggested values — a voluptuous
  `default=` never reaches the browser, so the form would have opened blank);
  the device's mdi: icon is never prefilled into the icon column
- remove: pick a SAVED row, then an explicit confirmation menu; cancelling
  keeps it (a detected app has no line to delete)
- edit/remove rows are offered only while something is stored
- every mutation writes ONE row through `_write_app_row`/`_drop_app_row`, so
  untouched rows and their icons stay byte-for-byte as they were

The Discover row was a silent no-op ("seems to load then do nothing"): it now
returns a one-line verdict the menu shows — N apps found / the Shield reported
none / the sweep could not reach the Shield / ADB is not set up — while still
never raising and never caching a failed sweep as an answer.

Also fixes `async_step_menu_again`, which the real FlowManager dispatches with
user_input and which raised TypeError for every menu offering that row (only
reachable through a stub before, hence unnoticed).

Docs: README and BOTH strings files (byte-identical) describe the editor, the
one-per-line format, the detected-app dropdown and the four Discover verdicts.
2026-10-12 10:02:09 +01:00
engineer eb62011ded fix(media-player): the browse root carries a real string id (t63)
The live report: "Browse media seems to spin and never finish loading". The cause is
in what async_browse_media HANDS BACK, not in the method — it returns with no awaits,
so nothing inside it can hang. The root node's media_content_id was None, and HA core
never serves a node without a string id: across the 43 BrowseMedia constructions in
the installed core media_player platforms, zero pass None (the roots use a
literal/module constant — arcam_fmj's "root", "apps", BROWSE_ROOT,
TUNER_PRESETS_ROOT — or "" for a pure container). The media-browser frontend keys the
node it is loading on that id, so a null id left the dialog re-requesting the same
node for ever.

BROWSE_ROOT_ID = "root" is now what the entity serves for EVERY requested id — the
root id, None, or anything a stale frontend asks for — so a repeat request is
idempotent rather than a loop.

The type was reassessed as well: MediaType.APPS (t61's choice) is what core pairs
with children=[...] — a folder OF apps — so on a zero-child node it invited the
frontend to treat it as an unloaded collection. No MediaType member means "a
directory with nothing in it", so the node carries the class it already declares
(MediaClass.DIRECTORY -> "directory"), which is exactly what can_expand=False and
children=[] say.

Verified over the real path the frontend uses — websocket_browse_media ->
player.async_browse_media -> payload.as_dict() — by driving HA's own decorated
handler against a real entity (tests/test_ha_api_drift.py, which the CI drift job
runs with Home Assistant installed): the root is a genuine BrowseMedia, its as_dict()
survives json.dumps, the id is the string "root", and browse(root_id) == browse(None)
in shape. Red proof on the pre-fix source: the new real-HA test fails at
`assert isinstance(payload["media_content_id"], str)` -> `isinstance(None, str)`.

The frontend bundle is not installed here (no hass_frontend) and there is no browser
to drive, so the rendered dialog is NOT exercised: this pins the backend half of that
path, end to end through the handler's decorators.
2026-10-12 09:42:07 +01:00
gitea-actions 4d7540e6f4 chore(release): v2026.10.12 [skip ci] 2026-10-12 08:25:36 +00:00
engineer 5ae2baa618 docs(installed-apps): the -3 output shape is documented, not observed (t62 nit c)
Release / release (push) Successful in 11s
Test / test (push) Successful in 41s
Test / ha-api-drift (push) Successful in 28s
Test / real-ha-config-flow (push) Successful in 31s
The mark t2's acceptance (c) asked for lived in connection.py's discovery section and
the README, but the parser for that output is this module, so a reader looking at the
parser met the claim ("the real output has one `package:<id>` per line") without its
provenance. It is now stated where that reader starts: the module docstring carries a
"MARK — documented, not observed (t62 nit c)" paragraph saying that `pm list packages
-3` printing `package:<id>` lines and `resolve-activity --brief` printing
`package/activity` are Android's documented shapes, not a captured run — no real
Shield was available when discovery was written (t57), and no fixture in this repo
captures either command — and `parse_package_list`'s tolerance note now points at that
mark instead of calling the shape "real". Wording mirrors the state=6 buffering
inference (ha_adb_common/media_session.py, echoed in media_player.py) and the existing
connection.py mark.

Docstrings only: stripping every docstring leaves an AST identical to HEAD's, so no
behaviour changes. The two existing marks stay accurate and are untouched
(connection.py still says the shapes are Android's documentation and the parsers in
ha_adb_common.installed_apps are deliberately tolerant; the README still marks every
discovery output shape as documented-not-observed).
2026-10-12 08:38:13 +01:00
engineer 181a51af95 chore(ci): run the real-HA config-flow tests and fail on silent skips
tests/test_config_flow_real_ha.py skips itself at import time when homeassistant is
absent, and a skipped file is indistinguishable from a passing one: the stub jobs
end in "N passed, 1 skipped" and exit 0, and the drift sweep never reads it. That is
how three real-HA-only facts stayed hidden while five gates were green.

New real-ha-config-flow job installs the real stack on Home Assistant's interpreter
floor (reusing HOMEASSISTANT_PYTHON_FLOOR from tests/ha_api_drift.py), runs the file
with -rs, and then checks the pytest summary line itself: it must report passed tests
and no skipped/failed/error line, because some pytest versions exit 0 for a file that
skipped itself. The header comment now names both jobs that install Home Assistant.
2026-10-12 08:36:51 +01:00
engineer 431b7cd8f4 feat(media-player): a switch Receiver is ensured on, and apps leave the browser (t61)
Two halves of the same live-run report.

RECEIVER AS A SWITCH. The user: "it would be nice if a receiver could just be a
switch, I don't need my volume scripts then because the receiver portion could
just make sure the receiver is on if it's a switch". `_ensure_receiver_on()`
runs at the top of the three playback entry points — turn_on, play_media,
select_source — and calls switch.turn_on only when the configured switch
receiver reports `off`: an `on` switch gets no call (idempotent), an
unavailable/unknown/missing state is left alone with a debug line rather than
guessed at, and a failing service call is logged, never raised — a receiver that
cannot be switched on must not stop the Shield from playing. Nothing else about
the switch is touched (no volume, no toggle), and nothing switches it from a
state read, a poll or a listener, so an off receiver stays off while the Shield
is idle. A media_player receiver keeps its behaviour exactly. select_source now
dispatches through the private `_dispatch_play_media`, so one user action looks
at the same switch once; `play_media` keeps its public signature and behaviour.
The Receiver field already accepted switch.* at the validation/strings layer
(t56, pinned by the real-HA tests); the strings copy now says what playback does
with it.

MEDIA BROWSER. The user: "Media Browser shows Apps. To me it should probably be
integrated with the home assistant media display, keep select sources for apps".
`async_browse_media` no longer publishes the app directory: it returns a valid,
standard BrowseMedia root (device title, DIRECTORY class, no content id, no
children) instead of a second, inconsistent copy of the list. Apps stay reachable
through source_list/source/select_source and play_media("app", <package>), which
is unchanged and complete; nothing else on this entity is browsable, so there is
no dead entry to select, and the drift gate's t37 D1 browse assertions still hold.

Tests: tests/test_receiver_switch.py pins all four promised states — off →
exactly one switch.turn_on; on → none; unavailable/unknown/missing/failing →
playback continues and nothing raises; media_player receiver → never switched —
plus the idle-Shield rule. The browse pins move from the removed tree to the
surviving surfaces (source_list, _source_package, _app_directory), and the harness
gains the real hass.states shape (FakeStates) and the real switch state constants.
README documents both changes; both strings files stay byte-identical.
2026-10-12 08:34:53 +01:00
engineer c884c7e1ab docs(discovery): the not-observed mark lands in the in-scope module (t62 nit c)
The "no real Shield was available, so this output shape is Android's
documentation rather than a capture" mark lived only in the test module, where a
reader of the discovery code never looks. It now sits in connection.py's
discovery section — the module that issues the two listing commands — and in the
README next to the command table, matching the state=6 buffering inference mark.

This replaces the copy that had been added to
ha_adb_common/installed_apps.py: t62's in-scope set is connection.py, sensor.py,
README.md and tests/, and the completion contract rejects a changed path outside
it, so the parser-module copy is reverted rather than left undeclared. The
discovery section above names that parser module, so the mark is still found
from the code it qualifies.
2026-10-12 08:25:41 +01:00
engineer afc3326fc3 docs(discovery): precise wording on the watchdog, the empty state, recovery
Three comments this repair touches were still loose: "never rebuilt on a state
poll" (the discovery table, not the watchdog — the sensor's own poll does
refresh it when it has no fresh answer), and the class docstring's "recorded as
unknown" now says where a reader actually sees it, since the sensor reports
not_connected while the transport is down and unknown once it is up but the
sweep delivered nothing.
2026-10-12 08:23:29 +01:00
engineer 1c414c2f70 docs(discovery): mark the command shapes documented, not observed (t62 nit c)
The "no real Shield was available, so this output shape is Android's
documentation rather than a capture" mark lived only in the test module, where a
reader of the discovery code never looks. It now sits in the module docstring
next to the commands it qualifies, and in the README's app-discovery section
next to the command table, matching the state=6 buffering inference mark in the
source ladder.
2026-10-12 08:22:20 +01:00
engineer 93d0afcdcf test(drift): flag unreachable statements and raise the call-argument floor (t62 F2)
Two gate gaps let the t62 findings ship, and both are closed here.

A pure-AST unreachable-statement pass (scan(): pass 6, entry point
scan_unreachable()): in every statement block, a statement after
return/raise/continue/break can never run. Per-block on purpose — an `else`, an
except handler or a nested function is a different block and not dead, so the
pass stays signal. Red proof: the pre-fix connection.py, saved from HEAD, is
flagged as `connection.py:1113: unreachable-statement: return at line 1110 ends
this block`; the live tree is clean. A new counter (statement_blocks, floor 500
of ~1102) keeps the "no findings" verdict from being a pass that stopped
parsing.

The call-argument floor was 20 while the walk counted 46, so deleting a
flow-display owner from HA_CALL_OWNERS did not trip it. It is 44 now, and the
suite proves the trip: removing async_show_menu yields 40 checked arguments and
`only 40 call_args (floor: 44)`. Measured removals: show_form 25, abort 38,
show_menu 40, create_entry 41, update_entry 41 all violate; the
single-call-site async_update_reload_and_abort (45) is the one owner a count
floor cannot watch, which is why the floor sits two below the count.

Also (t62 nit a): the two comments that pinned the t40 entity-attribute finding
to ``media_player.py:828`` now name the file and the attribute instead — the
line had rotted to an unrelated property.
2026-10-12 08:22:14 +01:00
engineer 0ef72a1fbb fix(connection): delete the unreachable duplicated label-drain body (t62 F2)
`_sweep_labels` ended with `return cached`, and the whole body of
`_drain_app_label_requests` — its docstring, the queue check, the fetch task —
followed it at the same indent level. Valid Python, never executed, and
invisible to every gate: the sweep, ruff and the stub suite all pass over it.
This is the second shipped artefact of its kind (an earlier review found a
duplicated statement), so the mechanism lands in the drift sweep next; here the
dead copy goes.

The `; echo __NSH_EXIT:$?` marker in `_dump_label` is documented while it is
touched (t62 nit b): it sits after a `head` pipeline, Android's sh has no
PIPESTATUS, so `$?` is head's status and never the dump's. Nothing reads its
VALUE — the parser strips the line — and the property that matters is that a
command which RAN leaves non-blank output, which is what separates "ran, no
label" (a cached negative) from "the transport delivered nothing" (a retry).
2026-10-12 08:22:10 +01:00
engineer c05e6fa929 fix(discovery): a sweep that could not run is not an answer (t62 F1)
The installed-apps sweep stamped an EMPTY answer on every failure path, so a
Shield that was asleep at the entity's startup sweep was cached as "0 apps" for
APP_LIST_TTL (6 h): `homeassistant.update_entity` then issued zero shells and
the sensor kept reporting 0/"pending" until the TTL or the Configure row's
force. A genuinely empty device was indistinguishable from it, so "ok" was
unreachable for an empty list.

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

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

Same class already fixed for app labels: a fetch that could not run is not an
answer. Also records that the `-3`/resolve-activity/pm-dump output shapes are
documented, not observed.
2026-10-12 08:19:10 +01:00
James Mackay ef42682250 fix(media-player): ON means no power or playback fact reported (t58 UNIT 0)
The t59 F2 ruling, implemented. The ladder said ON was for "no source reported
anything at all" but tested for something looser — no transport data — so a
source that ANSWERED "not playing" still fell through to ON. A source that
answers is not a source that is silent, and `on` is the "we cannot know" state.

The ladder now tracks an explicit `reported` flag, set by every source that gave
us a power or playback fact, and the last tier is `IDLE if reported else ON`.
Each case the ruling names now lands on IDLE:

  * a configured Jellyfin entity in ANY state — idle, off, standby, unavailable,
    or even absent from the state machine. It reports "not playing" without an
    early return, so a silent Jellyfin still cannot mask a live media session
    below it (both directions pinned);
  * a live media session that carries no playback flag — the app publishes a
    session but is not playing, paused or buffering;
  * a bare foreground app: either transport's answer counts (the protocol's
    current_app push or the ADB mResumedActivity parse);
  * a configured power entity, whatever it currently says;
  * protocol is_on / ADB awake, plugged or not.

`on` survives only for an entry where none of those exists. The t55 rules are
untouched: an off power entity still outranks a playing session, and no source
may mask playing/paused/buffering.

The module docstring's tier 6, the `state` docstring and the README's ladder
(moving "no data" to the precise wording) all say the same thing now, and the
README's one-line state table was aligned with it.

RED PROOF (the new tests against the parent tree): five of the reporting cases
read `on` where they must read `idle` — `assert 'on' == 'idle'` — plus the
"ON is unreachable with any source configured" shape test.

SIMULATED vs EXECUTED: no real Shield was available, so these are the documented
source shapes, not captures; the state=6 → buffering mapping stays marked
inferred. No browser exists either, so the README pins are textual and the
production behaviour is exercised against real HA's state machine in
tests/test_ha_api_drift.py.
2026-10-12 08:04:06 +01:00
James Mackay ff8dd1c454 fix(discovery): a cached negative is an answer, not a miss (t57)
The sweep asked `app_label()` whether a package still needed a label, and
that accessor answers "what should be displayed" — so a definitive negative
(a package whose pm dump ran and carried no label) came back as None,
indistinguishable from "never asked". Every sweep therefore re-dumped every
label-less package, throwing away the 24 h cache the label path exists for,
and a second sweep cost a full dump round for the whole app list.

`_known_label(package) -> (known, label)` is the accessor that keeps the two
apart: known-and-empty means the device already answered, so the row falls
back to its package id with ZERO further round trips. A forced re-sweep now
issues only the two list commands.

Pinned by tests: a second sweep dumps nothing at all (negatives included),
and a package whose fetch FAILED (no output — the UNIT 0 rule) stays
uncached and is retried by the next sweep, recovering its label.
2026-10-12 07:42:38 +01:00
James Mackay 364d4b7da3 test(discovery): pin that the discovered list is a superset
The live complaint in one assertion: every app the user has, plus the curated
four, is offered — TiviMate, Wholphin, Netflix, YouTube, Kodi, Plex, Spotify
and an app only this device knows, with the launcher added and no system app
invented that `pm list packages -3` never reported.
2026-10-12 07:40:41 +01:00
James Mackay 95d8d20571 docs(readme): the setup menu's rows and the sweep's socket behaviour
The setup flow's menu rows are named the way Home Assistant dispatches them
(Receiver / Connection / Set up ADB / Pair the remote protocol / Create the
entry), a skip leaves its pairing row while a success removes it, and the
discovery burst shares the watchdog's connection with adb-shell serialising
the I/O.
2026-10-12 07:39:38 +01:00
James Mackay 3a55519b74 fix(config-flow): the setup menu was unreachable under real HA (t57 UNIT 0a)
t61 F1 (BLOCKER) + F2 + F3 + F5, all reproduced against a real FlowManager
before fixing. The OPTIONS flow was verified good and is untouched by this
commit; the SETUP flow was broken in three independent ways.

F1 — `_show_setup_menu` passed `errors=` to `async_show_menu`, which has no
such parameter (HA 2026.10.1: step_id / menu_options / sort /
description_placeholders). Every path that went near the menu therefore
raised `TypeError` the moment the first form was submitted or ADB pairing
was skipped. The path map, driven end to end: FOUR of the five ordinary
paths died before create_entry ("ADB skip", "ADB paired + protocol off",
"ADB skip + protocol skip", "pair ADB"); only "protocol only" survived, and
it never showed the menu — so a new entry could only be created on that one
path and the sub-screens were unreachable.

F2 — the menu's rows broke the rule this repo documents: HA dispatches a row
to `async_step_<row id>` AND dispatches a submitted form by its OWN step_id,
so a row named `adb` with a handler called `async_step_adb_pair` is an
UnknownStep, and a row named `receiver` rendering `step_id="setup_receiver"`
is a form that cannot be submitted. The rows are now their handler names
(`receiver`, `connection`, `adb_pair`, `protocol_pair`, `finish`) and the
two sub-screens render their row id. There is also a `_paired_adb` guard
now, so a completed ADB pairing stops offering that row — the rule the
protocol row already followed.

F3 — both menus keyed their row labels under a bare `menu`, which core does
not read (56 core strings files use `menu_options`), so every row fell back
to its raw id in the browser. Renamed in both strings files.

F5 — the per-command script merge is field-wise now: a `script_<command>`
key that is present and empty removes that command, one with a value sets
it, and a key the submission does not carry is untouched — the same rule as
every other option. The wholesale rebuild depended on the page always being
complete, so a partial submission silently wiped the commands it omitted.

THE SYSTEMIC HALF (F1), closed as asked: `ha_api_drift.HA_CALL_OWNERS` now
covers the flow-display APIs (`async_show_menu`, `async_show_progress`,
`async_show_progress_done`, plus the existing form/abort/entry owners), so
the drift engine resolves their REAL signatures and flags an unknown
keyword. `tests/test_ha_api_drift.py` carries a pre-fix source using
`errors=` to prove the new owner is non-vacuous. Every harness stub now
mirrors the real signature (no `errors` on a menu) and a stub-level test
pins that parameter set, so a stub cannot go back to accepting anything.
On the pre-fix tree the drift engine reports exactly this:

    config_flow.py:898: unknown-kwarg: async_show_menu() has no 'errors'
    parameter; real signature: (self, *, step_id=None, menu_options, sort,
    description_placeholders) -> _FlowResultT

TESTS: tests/test_config_flow_real_ha.py gains a SETUP section that drives
the real flow — the first form, the ADB skip, the pairing guard, EVERY menu
row (dispatch AND re-submittable step_id), an accepted sub-screen
submission, a full connection+receiver+finish walk merging into one entry,
the menu's pairing note, and the five-path map as a parametrised test.
11 of those fail on the pre-fix tree; the drift engine failure above is the
12th. The stub suite additionally pins the menu stub's signature and the
`menu_options` key in both strings files.

Also in this commit (t57 F4): the real-HA file claimed entity-valued pages
could not be driven there, which was false (it has always submitted
`switch.soundbar`). The claim is gone and the scripts page is driven through
SET (two scripts selected, real entity ids) → CLEAR (one selector emptied)
→ SAVE → REOPEN, so the SET half is pinned in the real flow and not only
against the selector objects.
2026-10-12 07:39:01 +01:00
James Mackay 5eefd0c6d0 test(discovery): fixtures for the parsers, the bounded sweep and the surfaces
tests/test_installed_apps.py is new and is where the discovery claims are
pinned. NO REAL SHIELD WAS AVAILABLE, so the command output is fixture-based
in the documented Android shapes — `pm list packages` lines, the verbose
`resolve-activity` header followed by `package/activity`, and `pm dump`
carrying `application-label` — including a localized label (the default
locale wins), a label-less app (a definitive negative, and still a row) and
the launcher (a system app that must be present).

Covered: both parsers plus their tolerances (noise, duplicates, blank,
a bare `package/activity` line, the settings entry point that is not a
launcher), the user+launcher merge and its de-duplication, the icon
heuristic and that it is always a string, the sweep's commands, its
concurrency bound (peak <= APP_SWEEP_CONCURRENCY and > 1, so it is bounded
AND genuinely concurrent), cache reuse with zero shells, the TTL, shared
sweeps for concurrent callers, and every degradation path (no poller,
disconnected, mute transport — empty list, no loop, no raise). The sensor's
count/table/reason and the "a failed refresh is a no-op, not an exception"
behaviour are pinned too.

The config-flow side is in tests/test_config_flow_ux.py: the table lists
every discovered app, a discovered app the map never mentions is appended,
the user's name wins for the same package, an `mdi:` value never reaches the
icon column, rendering issues no shell, the Discover row runs one forced
sweep and returns to the menu, a failed sweep still renders, and the merged
table round-trips into the saved options.

Also: tests/test_config_flow_real_ha.py learns the new row, the standalone
stub accepts `description_placeholders` (which the apps form now passes),
and the state=6 wording in media_session.py/media_player.py is the t57
UNIT 0c reword.
2026-10-12 07:29:01 +01:00
James Mackay a56ec38170 feat(config-flow,sensor): the discovered apps reach the UI and dashboards (t57)
SURFACES
- The Configure menu gains a **Discover installed apps** row. A row rather
  than a form step, because a menu choice is dispatched with no user input:
  it awaits the manager's bounded sweep and returns to the menu with a line
  saying what was found. Discovery is therefore always explicit — never a
  per-poll cost — and a failed sweep still renders the menu.
- The Apps table opens on the REAL app list: the stored rows first (kept
  position, name and icon untouched) and then every discovered app, named
  by its device label or its package id when the label fetch did not answer.
  The user's entry for a package always wins over the device label.
- `sensor.<name>_installed_apps` (ADB-enabled entries only): state = app
  count, `installed_apps` attribute = the same table, `discovery` attribute
  = ok / pending / not_connected / adb_disabled. It polls through the
  manager's TTL cache, so a poll on a fresh table issues no shell, and the
  standard `homeassistant.update_entity` action refreshes it.

The discovered icon is deliberately NOT written into the icon column: that
column is documented as a picture URL or a `camera.*` entity feeding
`entity_picture`, and an `mdi:` name there would be a value the media
player cannot use. The icons are offered in the installed-apps attribute,
and the table's description names them.

strings.json + translations/en.json: the `installed_apps` entity name, the
Discover row label, and the menu description now carries {discovery}, so the
page says how many apps were found (or why none were).

README: an "App discovery" section documenting both commands, how system
and launcher entries are treated, the label/caching semantics, the icon
rule and its limits, the sensor surface and the fallback behaviour — and
that the source-list fallback stays the user's map, not the discovery.
2026-10-12 07:28:55 +01:00
James Mackay ee3f2845af feat(discovery): enumerate the apps installed on the Shield (t57)
The live complaint ("it doesn't find all the apps") came from the curated
list: only four packages were ever offered, so anything the user had
installed elsewhere was invisible. The device knows its own apps.

ha_adb_common/installed_apps.py parses the two queries the sweep makes:

* `pm list packages -3` — every third-party (user-installed) package;
* `cmd package resolve-activity --brief -c android.intent.category.HOME`
  — the ACTIVE launcher, a system app that is nonetheless what the user
  opens, so it is appended rather than filtered away.

System apps are otherwise not enumerated (a Shield ships a few hundred and
they are not launchable apps); the parsers are tolerant of the noise a real
adb shell can prepend and of duplicate lines.

ShieldConnectionManager gains the sweep:
`async_refresh_installed_apps(force=False)` returns a cached
`[{package, name, icon}]` table, and `installed_apps()` is a pure read that
never issues a shell — so a state poll costs nothing.

Naming stays on the one path: the sweep calls `_dump_label`, the same
`pm dump` + parse + 24 h cache the entity path uses, now extracted so the
two cannot drift, including the t57 UNIT 0 rule that a fetch that returned
nothing is NOT cached. Labels are fetched APP_SWEEP_CONCURRENCY (4) at a
time with APP_SWEEP_SPACING (50 ms), so a typical Shield finishes in
seconds rather than the minutes a one-per-watchdog-cycle drain would take
while the socket still sees a trickle.

Failure is a normal outcome: no poller, a disconnected transport or a mute
shell stores an EMPTY table with a stamp and returns [], so nothing retries
in a loop and every surface degrades to "no discovered apps".

Icons: `pm dump` does NOT expose an app's icon resource — an extracted
APK's icon is a bitmap only its own code resolves — so this is honestly a
heuristic: APP_ICON_FALLBACKS (a curated package -> mdi: map) with
APP_ICON_GENERIC (mdi:television) for anything unknown, and always a string.
2026-10-12 07:28:50 +01:00
James Mackay 9461d69469 fix(connection): a label dump that returned nothing is a FAILED fetch (t57 UNIT 0)
t59 F1 (HIGH). `ShieldAdbPoller.shell` swallows its error and answers None
(adb_client.py:144-155), and `_fetch_app_label` read that as "the dump
ran and found no label" → `cache_app_label(pkg, None)` → `('', stamp,
resolved=True)`. That is a DEFINITIVE negative for APP_LABEL_TTL = 24 h,
so after ONE transient failure `app_label()` never re-enqueued, exactly
one `pm dump` was ever attempted, and a later good dump could not
recover the label — `sensor.native_value` and `media_player.app_name`
stayed on the package id for every later render.

The dump now carries an exit marker (`; echo __NSH_EXIT:$?`), so the
three outcomes are distinguishable:

* output with an `application-label` → cache the label;
* output that ran and carried none (the marker is still there) → cache
  the empty string, the definitive negative that keeps t24 F1's
  zero-round-trip rule for a genuinely label-less package;
* no output at all (`None`, or blank) → cache NOTHING and let the next
  render re-enqueue, logged at debug.

The `resolved` flag goes with it: `cache_app_label` was its only writer
and always passed True, so an entry in `_app_labels` is now simply "a
fetch RAN" and the dead third field (`(label, stamp, True)`) is gone.

Tests drive the FakePoller-returns-None AND returns-'' shapes through
the sensor and through `media_player.app_name`, plus the label-less
package that must still be negatively cached. The stub poller now models
both real outcomes of a dump (an answer, or a transport that delivered
nothing) instead of conflating them — which is why the old tests could
only pin the raising-shell branch that already worked. 4 of the new
tests fail on the pre-fix tree, showing `('', stamp, True)` stamped.

Also in this commit:
- t59 F3: running_apps.py set `app["state"] = "paused"` twice — one line.
- t59 F4: no captured fixture contains state=6, so the "the Shield's own
  sessions report 6 like stock Android" wording in README and the
  media_session/media_player docstrings is reworded as INFERRED from the
  `state=PlaybackState {state=N, …}` line shape. A `git notes` entry on
  07682ff records the same correction against the t55 commit body.
2026-10-12 07:21:34 +01:00
James Mackay ca3fdec218 test(config-flow): drive the menus, pin the tables, probe real HA
The flow tests drove `async_step_init` as if it were the one form, so they
had to learn the menu: a shared helper picks the sub-screen that owns a
payload's keys out of the flow's own `menu_options` (no test re-implements
the routing), and a `save=True` flag walks to "Save and close" so the
assertions read as they did before.

New coverage for this task:

- the menu offers every sub-screen and every row has a handler named
  after it, with the rendered step_id equal to the row id (the invariant
  real HA enforces and the first draft broke);
- edits on one sub-screen are not lost by another, and a page that is
  never saved never writes the entry;
- the apps table: add / edit / remove a row, the icon column round-trips,
  the empty state renders without data, a blank field clears the map, and
  the suggested value is TEXT (the `[object Object]` layer);
- a receiver the selector offers `switch` for, stored and pre-filled;
- the setup menu's rows, their handlers and a connection+receiver merge.

tests/test_config_flow_real_ha.py is new and is the empirical half: it
drives the real `FlowManager`, real probatio validation and the real
frontend serialiser (`to_field_list` + `cv.custom_serializer`) — the call
the HTTP flow view makes — so the menu dispatch, the cleared-selector
shapes and the wire serialisation are established rather than assumed.
It skips when homeassistant is absent; CI runs it with:

    uvx --with homeassistant --with adb-shell pytest tests/test_config_flow_real_ha.py

53 of the stub tests and 11 of the real-HA ones fail on the pre-fix tree.
2026-10-12 07:17:41 +01:00
James Mackay b0adbd2dfd docs(readme): the configure menu, the apps table and the switch receiver
The Configure page is a menu of sub-screens now, the Apps row is an
editable one-row-per-app table, and a receiver may be a `switch.*`.
2026-10-12 07:17:23 +01:00
James Mackay cd8f528a27 feat(config-flow): menu of sub-screens on setup and options
Both configure pages were one long form: a field you wanted to change
meant re-submitting everything, and the options page rendered the apps
map as `[object Object]`.

Verified against a real Home Assistant install (not assumed): the HTTP
flow view serialises a field's suggested value verbatim, so a dict handed
to a `type: string` field reaches the browser as a non-string; and real
HA validates a submitted page against the schema the step returned, where
the built-in EntitySelector rejects `''` and `None`.

- The options flow is a MENU (Main setup / Receiver / Apps / Scripts /
  Save and close). Each sub-screen owns a disjoint set of keys and merges
  into one pending dict under a single rule: a key the submission CARRIES
  is authoritative (empty clears), a key it does not carry is untouched.
  The per-command script dict is rebuilt from the submitted script fields,
  so unsetting one persists instead of surviving as a stale key.
- The sub-screen step_id IS the menu row id. Real HA dispatches a menu
  choice and a submitted form by that same name, so a mismatch is an
  UnknownStep or an unsubmittable page (the setup menu first tried a
  "setup_menu" step id and dead-ended in real HA).
- The apps map reaches the suggestion layer serialized to the documented
  `package=name[|icon]` lines, and parses back to both maps.
- The setup flow keeps its first form and gains a menu after it; pairing
  completion returns to the menu, and the "ADB then protocol" sequence
  still happens automatically.
- Receiver accepts a `switch.*` as well as a media_player.

Tests: 522 pass on stubs, and tests/test_config_flow_real_ha.py drives
the real FlowManager + probatio + to_field_list; 53 + 11 of those fail on
the pre-fix tree.
2026-10-12 07:17:21 +01:00
James Mackay 74e0549196 docs(readme): the t55 state ladder and the label tier's retry rule
The State ladder section still described tier 7 as "app open → on" and
tier 8 as "On — powered, awake, nothing more specific", which the t55
ladder change makes wrong: an open app with nothing playing is `idle`,
`buffering` is a state, and `on` now means "no source reported anything
at all". The retired-piece table gets the same vocabulary, and the
app-name row records that the manifest label is fetched for the app on
screen and retried after a failure instead of being cached as a miss.
2026-10-12 06:53:13 +01:00
James Mackay 85e96f3c60 docs(sensor): name where the Current App name chain comes from (t55)
The Current App sensor already resolved through the shared
`resolve_app_name` chain, so the package-id live symptom was never a
missing tier — it was the label path being silenced (fix in the previous
commit). Its docstrings now say so explicitly: `_package` is the app on
screen (protocol push → ADB mResumedActivity → recents as a last resort)
and `native_value` resolves exactly that package's map entry or cached
device label, which is why the label fetch must be driven off it. The
behaviour is unchanged; the comment is what was missing.
2026-10-12 06:53:13 +01:00
James Mackay 0c7e405108 fix(connection): a failed app-label fetch is retried, not cached as a miss (t55)
The Current App sensor and media_player.app_name showed a package id in
the live run. Root cause was in the manifest-label tier: `_fetch_app_label`
returned early (no poller, ADB down) or logged and returned on a shell
error WITHOUT touching the cache, but `app_label` had already put the
package in `_app_label_requests`; the drain then threw the request away,
so a render between the failure and the next cycle re-enqueued — while
nothing distinguished "the device answered, this package has no label"
from "we never got an answer". Nothing was visibly retried, and the label
tier could stay silent indefinitely.

The cache is now (label, stamp, resolved) and records the FETCH, not the
wishful result:

  * a fetch that RAN caches its outcome for APP_LABEL_TTL — the label, or
    the empty string for a genuinely label-less package. Definitive: no
    re-enqueue, so the t24 F1 per-heartbeat round-trip tax stays closed.
  * a fetch that could NOT run stamps nothing, logs at debug (so a
    package id in the UI is diagnosable), and the next render retries.
  * a queued or in-flight fetch is not enqueued twice, so a burst of
    renders still costs one `pm dump` per watchdog cycle.

`cache_app_label()` is the one public writer of the cache (tests and any
future out-of-band reader no longer poke the private tuple), and
`app_label_requests`/`app_label_inflight` expose the queue for tests.

The old 2-tuple seeds in the label tests move to `cache_app_label`.
2026-10-12 06:53:09 +01:00
James Mackay ccde3cff71 fix(media_player): the state ladder is idle/playing/buffering/off; ON means no data (t55)
The live report: the card showed "On" where the user expected Idle,
Playing, Buffering or Off, and "On" appeared to override playing states.

Root cause: `state` returned MediaPlayerState.ON for two different
situations — "powered on with an app open and nothing playing" (tier 3.5)
and "we have no idea" (the fallthrough) — while the power gate above the
media tiers returned IDLE whenever power resolved to None, which is
exactly the case in which Jellyfin a media session DO know what is
playing. Media truth was therefore lost whenever the protocol push and
the ADB wake poll had not reported yet.

The ladder is now:

  1. OFF     — the power entity says off, or the protocol is_on push is
               False. Explicit power outranks media, so a stale Jellyfin
               session cannot resurrect playback on a device that is off.
  2. intent  — a just-sent play/pause/stop.
  3. Jellyfin — playing / paused.
  4. session  — the ADB media session: buffering / playing / paused.
  5. IDLE     — ADB standby (powered but asleep), or the device is on
               (protocol is_on / ADB awake / the plug) with nothing
               playing. "Powered on, app open, nothing playing" is IDLE.
  6. ON       — nothing reported anything at all: no transport, no power
               entity, no Jellyfin, no session. ON never masks a
               playing/paused/buffering source and never appears while
               any source reported a power or media state.

Buffering also carries the playback controls: a buffering session
advertises PLAY/PAUSE/STOP exactly like a playing one, and the
now-playing dict names it "buffering" instead of collapsing it into
"playing" and contradicting the entity state.

The module docstring documents the ladder and the ON-only-with-no-data
rule, and the new TestStateModelT55 / TestCurrentAppNameEndToEndT55 pin
one test per source (Jellyfin, intent, media session, power entity,
protocol push, ADB wake) that a reporting source outranks ON/IDLE.

Red-proof against the previous tree (new tests, old source): 21 failed /
17 passed, with the three headline symptoms visible as literal asserts —
"on" where "idle" belongs, "idle" where "on" belongs (no data), and
"playing" lost because power was unknown.
2026-10-12 06:53:04 +01:00
James Mackay 07682ff082 feat(media_session): map Android PlaybackState 6 to buffering (t55)
The media session parser only understood states 2 (paused) and 3
(playing), so a session refilling its buffer (Android state 6, which the
Shield's own ExoPlayer/MediaSession sessions report exactly like stock
Android) read as "idle" or vanished into the foreground tier.

MediaSessionInfo gains an independent `buffering` flag (playing/paused/
buffering are now three flags off one integer), the session sort puts
buffering right behind playing — it is mid-playback, so it outranks a
paused session when the ladder picks one — and the running-apps
enrichment names a buffering live/last-known session "buffering" instead
of falling into "idle".

The MediaPlayerState stub gains BUFFERING and STANDBY so an in-suite
reference to a member real HA does not have fails here (real HA 2026.10
exposes off/on/idle/playing/paused/standby/buffering).
2026-10-12 06:52:58 +01:00
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
59 changed files with 27021 additions and 1383 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}"
+133 -4
View File
@@ -7,10 +7,11 @@
# firmware captures live in tests/fixtures # firmware captures live in tests/fixtures
# (how to capture more: docs/RESEARCH_SHIELD.md §8). # (how to capture more: docs/RESEARCH_SHIELD.md §8).
# #
# The `ha-api-drift` job is the ONE place Home Assistant is installed: every # Home Assistant is installed in exactly two jobs: `ha-api-drift` (below) and
# other job validates our code against hand-written stubs, which stay green # `real-ha-config-flow`. Both exist because every other job validates our code
# even when a call site no longer matches the real API. That is how three # against hand-written stubs, which stay green even when a call site no longer
# drifts reached master (t36 D1/D2/D3) — see tests/ha_api_drift.py. # matches the real API. That is how three drifts reached master (t36 D1/D2/D3) —
# see tests/ha_api_drift.py.
name: Test name: Test
@@ -112,3 +113,131 @@ jobs:
- name: HA API-drift sweep - name: HA API-drift sweep
run: python3 -m pytest tests/test_ha_api_drift.py -q run: python3 -m pytest tests/test_ha_api_drift.py -q
# The real-HA tests are the ONLY place the integration meets the installed Home
# Assistant — and EVERY one of them SKIPS ITSELF at import time when
# homeassistant is absent (pytest.importorskip at module scope). A skipped file
# and a passing one are indistinguishable in the jobs above: `python3 -m pytest
# tests -q` ends in "N passed, 3 skipped" and still exits 0, and the drift sweep
# never touches them. That is exactly how three real-HA-only facts stayed hidden
# while five gates were green:
#
# * a menu row id must equal its async_step_<id> method name (otherwise the
# real FlowManager raises when the row is dispatched),
# * the rendered step_id must equal the row id, and
# * async_show_menu takes no errors kwarg.
#
# t14 added a second file, and with it the reason this job GLOBS instead of
# naming one: tests/test_adb_command_real_ha.py proves the adb_command action is
# SELECTABLE (the runtime service description carries the entity target the
# frontend builds its picker from — `async_register` has no target parameter) and
# that a real async_call returns the command's output, and it also caught an
# `async_entries_for_device` that is a MODULE-LEVEL helper, not a registry
# method. A named file list would have left it skipping silently here. The
# pattern `tests/test_*real_ha.py` is the rule: a new real-HA file is picked up
# by virtue of existing, and the files that ran are printed.
#
# So this job does two things the others cannot: it installs the real stack on
# Home Assistant's interpreter floor, and it FAILS LOUDLY if the tests are
# skipped instead of run (or if the pattern matches nothing). Do not "simplify"
# the guard at the bottom away — a test file that skips silently is
# indistinguishable from one that passes.
real-ha-config-flow:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Provision the interpreter Home Assistant needs
uses: actions/setup-python@v5
with:
# Same floor as the ha-api-drift job: Home Assistant 2026.10.1
# declares Requires-Python >= 3.14.2 (tests/ha_api_drift.py holds our
# copy of that number). This resolves the newest 3.14.x patch.
python-version: "3.14"
- name: Ensure uv is available
run: |
set -euo pipefail
# uvx runs the exact command this gate was validated with locally;
# runner images are not guaranteed to ship it.
if ! command -v uvx >/dev/null 2>&1; then
python3 -m pip install --quiet uv \
|| python3 -m pip install --quiet --break-system-packages uv
fi
uvx --version
- name: Check the interpreter satisfies Home Assistant's floor
run: |
set -euo pipefail
# The floor comes from tests/ha_api_drift.py — one source of truth for
# this guard and the metadata pin in tests/test_ha_api_drift.py. uvx
# resolves the same interpreter the test step will use, so a too-old
# interpreter fails here rather than halfway through the install.
uvx --python 3.14 python - <<'PY'
import sys
from pathlib import Path
sys.path.insert(0, str(Path("tests").resolve()))
from ha_api_drift import HOMEASSISTANT_PYTHON_FLOOR as FLOOR # noqa: E402
needed = ".".join(str(part) for part in FLOOR)
if sys.version_info < FLOOR:
print(
f"Home Assistant needs Python >= {needed}, but this job runs "
f"{sys.version.split()[0]}."
)
raise SystemExit(1)
print(f"interpreter ok: Python {sys.version.split()[0]} satisfies >= {needed}")
PY
- name: Real-HA tests
run: |
set -euo pipefail
LOG=/tmp/real-ha.log
# THE PATTERN, not a filename: every tests/test_*real_ha.py runs, so a new
# real-HA file cannot skip silently merely because nobody listed it here.
# An empty match is a failure too — but it is checked first so the log says
# why instead of blaming pytest's argument parsing.
shopt -s nullglob
FILES=(tests/test_*real_ha.py)
shopt -u nullglob
if [ "${#FILES[@]}" -eq 0 ]; then
echo "::error::no tests/test_*real_ha.py file exists — this gate would pass vacuously"
exit 1
fi
echo "real-HA files collected (${#FILES[@]}): ${FILES[*]}"
# Verbatim the command this gate is validated with locally; -rs is what
# makes a silent skip visible in the log.
set +e
uvx --python 3.14 --with 'homeassistant>=2026.10' --with adb-shell \
--with pytest-asyncio \
pytest "${FILES[@]}" -q -rs 2>&1 | tee "$LOG"
rc=${PIPESTATUS[0]}
set -e
if [ "$rc" -ne 0 ]; then
echo "::error::the real-HA tests exited $rc"
exit "$rc"
fi
# THE GUARD. Some pytest versions exit 0 for a file that skipped
# itself, so the exit code above is not enough. The last line of a -q
# run is the counts line ("50 passed in 1.6s" / "1 skipped in
# 0.01s" / "no tests ran in 0.01s"); it must report PASSED tests and
# nothing else — a skip, a failure and a zero-collection run all fail
# here, which is what makes the glob safe to widen.
summary=$(grep -v '^[[:space:]]*$' "$LOG" | tail -n 1)
echo "pytest summary: $summary"
case "$summary" in
[0-9]*" passed"*) ;;
*)
echo "::error::the real-HA tests did not run (summary: $summary)"
echo "These files skip themselves when homeassistant is missing — a skip here is a FAILURE, not a pass."
exit 1
;;
esac
case "$summary" in
*skipped*|*failed*|*error*|*"no tests ran"*)
echo "::error::the real-HA run was not a clean pass (summary: $summary)"
exit 1
;;
esac
+876 -53
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).
@@ -61,28 +63,56 @@ remote protocol is the fast push/command path:
1. **Plug off** — the power switch says `off` → `off`. 1. **Plug off** — the power switch says `off` → `off`.
2. **Protocol power push** (`is_on`) — `False` → `off`; `None` falls through. 2. **Protocol power push** (`is_on`) — `False` → `off`; `None` falls through.
3. **ADB wake** (exact `mWakefulness` parse) — asleep → `idle` (standby). 3. **Intent** — a just-sent command's target state, self-healing against
4. **Intent** — a just-sent command's target state, self-healing against
reality within 30 s (retry goes over the transport that served it). reality within 30 s (retry goes over the transport that served it).
5. **Jellyfin** — `playing` / `paused` while a Jellyfin session is active. 4. **Jellyfin** — `playing` / `paused` while a Jellyfin session is active.
6. **ADB media session** — `playing` / `paused` for apps that publish a 5. **ADB media session** — `playing` / `paused` / `buffering` for apps that
session (foreground app preferred). publish a session (foreground app preferred). `buffering` is the media
7. **Foreground fallback** — an app is open (`current_app` protocol push, session's PlaybackState **6** (`state=PlaybackState {state=6, …}`) — the
then the ADB `mResumedActivity` parse) but publishes no media session → stock-Android buffering value, **inferred** from that line shape: no captured
`on`. Its identity comes from the TTL-capped last-known-state cache, the fixture here contains `state=6` and no real device was available. A buffering
last channel entered, or the zero-cost "audio playback" hint session advertises the playback controls exactly like `playing`.
(`media recently active`) — never resurrecting `playing` past the 60 s 6. **Idle** — the device is powered (protocol `is_on` true, ADB awake, or the
state TTL. power plug on) and nothing is playing, or ADB reports it asleep (standby).
8. **On** — powered, awake, nothing more specific. A powered Shield with an app open and nothing playing reads `idle`.
7. **On** — **no source reported a power or playback fact** (t58 UNIT 0; the
earlier wording said "no data", which was too loose). `on` is the "we cannot
know" state and the only situation it describes, so every source that
*answers* — including an answer of "not playing" — stops the ladder at
`idle` at worst, never here:
- a configured **Jellyfin** entity in **any** state: `idle`, `off`,
`standby` and `unavailable` all mean "its session is not playing", so they
report — and because the ladder keeps going, a silent Jellyfin still
cannot mask a live media session below it;
- a **live media session with no playback flag** — the app publishes a
session but is not playing, paused or buffering;
- a **bare foreground app** — the protocol's `current_app` push or the ADB
`mResumedActivity` parse answered;
- a configured **power entity**, whatever it currently says;
- protocol `is_on` / ADB awake, plugged or not.
`on` survives only for an entry where none of those exists, and it never
masks a `playing`/`paused`/`buffering` source.
The foreground tier is what keeps an app's identity visible while the state is
`idle`: the `current_app` protocol push (then the ADB `mResumedActivity`
parse), with its title from the TTL-capped last-known-state cache, the last
channel entered, or the zero-cost "audio playback" hint
(`media recently active`) — never resurrecting `playing` past the 60 s state
TTL.
TiviMate (`ar.tvplayer.tv`) is the documented no-session case: it publishes 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 5–6 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 +126,57 @@ 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
nvidia_shield:
- name: Living Room # the entry's title (default: NVIDIA Shield)
host: 10.0.0.31 # the Shield's IP — the one address field
adb_port: 5555 # optional; the standard ADB port
adb_enabled: true # optional; the block's shape decides (see below)
```
`host` is the current key. `protocol_host` and `adb_host` are **still accepted**
(protocol first, then ADB) so an existing `configuration.yaml` keeps seeding:
the address is a prefill hint either way, and the PIN/ADB pairing still happens
in the flow. `adb_enabled` defaults to what the block implies — an address under
`host`/`adb_host` marks ADB as part of the block, a protocol-only block leaves it
off — and an explicit `adb_enabled` wins over the shape. Blocks without any
address are ignored with a warning.
The seed carries the address, the ADB toggle and the entry's name; everything
else (Jellyfin, receiver, power switch, app name/icon maps, channel timing, the
`adb_command` service) is set on the entry's **Configure** page after setup, or
during the interactive flow. `registered_intents:` is the one exception — it may
be seeded too, because registering several intents by hand is exactly the kind of
work a `configuration.yaml` should be able to do:
```yaml ```yaml
nvidia_shield: nvidia_shield:
- name: Living Room - name: Living Room
adb_host: 10.0.0.31 # or protocol_host for the PIN-pairing hint host: 10.0.0.31
adb_port: 5555 registered_intents:
- id: netflix_title # the button's key (optional)
name: "Netflix: open a title" # what the button and the action call it
package: com.netflix.ninja
activity: com.netflix.ninja/.MainActivity # a component…
action: android.intent.action.VIEW # …or an action (+ categories)
categories: [android.intent.category.DEFAULT]
data: "https://www.netflix.com/watch/{id}" # {placeholders} from the fields
fields:
- {key: id, type: string, required: true}
``` ```
`protocol_host` is a prefill hint only (the PIN pairing still happens in A seeded intent is **identical to one you typed** — same validation, same button,
the flow). Blocks without protocol/adb hosts are ignored with a warning. same `send_intent` behaviour. The seed is honoured **only while the UI holds
nothing**: it is applied when the entry is created (the Intents screen lives in
the options flow and never writes a seed back), and an existing entry is never
re-seeded. A seeded row that does not validate is **dropped with a warning naming
it**, because there is no form to show an error on in `configuration.yaml`.
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
@@ -117,33 +187,258 @@ the contractual path (YAML is the seed, the flow is the truth).
### Raw `adb_command` service (opt-in) ### Raw `adb_command` service (opt-in)
When enabled in the integration's options (`adb_command_enabled`, **default When enabled in the integration's options (`adb_command_enabled`, **default
off**), the integration registers the `nvidia_shield.adb_command` service: off**), the integration registers the `nvidia_shield.adb_command` service: send a
send a raw ADB shell command to the entry's persistent connection raw ADB shell command to the entry's persistent connection.
(`{"command": "your-command", "entity_id": "media_player.<yours>"}` — the
entity is optional when exactly one entry provides the service). This is the
same security posture as the Home Assistant core `androidtv` integration: a
documented, opt-in escape hatch for debugging and automation, registered at
setup and unregistered when the last participating entity unloads. It
rejects (logs a warning, sends nothing) when the ADB connection is down.
### Migration from mediasession2mqtt + universal-player glue **Picking the Shield in the UI.** The action declares a target, so **Developer
tools → Actions** (and the automation editor) offers a target picker whose entity
list contains the Shield's `media_player` entity — the same way the core
`androidtv` integration's action worked. Select the Shield there, type the
command, and call it.
Home Assistant renders that picker from the action's *target description*, which
the integration publishes at setup — that is why the Shield is selectable at all.
The `mediasession2mqtt` bridge sensors and the universal media player YAML **As YAML / automation data**, the same call:
templates the fork replaces are retired by the ladder above: the media player
entity already carries the DAG attributes (`media_dag_running_apps` with ```yaml
foreground/last-known enrichment, `media_dag_now_playing` with its source - action: nvidia_shield.adb_command
tag) that media-zone orchestration consumed from those sensors. See the target:
appendix in [`docs/RESEARCH_SHIELD.md`](docs/RESEARCH_SHIELD.md) for the entity_id: media_player.shield
retirement mapping and the universal-player glue it replaces. data:
command: getprop ro.product.model
response_variable: adb # optional: keep the output in a variable
```
- `command` (**required**) — the shell command. An empty one is an error
(`invalid_command`), never a silent no-op.
- The target (**required**) is the Shield's `media_player` entity, or its
`device_id` (`target: {device_id: …}` / `entity_id: …`). There is **no
"pick the only one" fallback**: with no target the call fails with `no_target`,
two targets fail with `ambiguous_target`, and an entity that does not participate
(its entry has the option off) fails with `unsupported_target` — a raw shell
command is never sent to a Shield you did not select.
- **The output comes back** in the response under `adb_response` — the same key the
deprecated `androidtv.adb_command` action returned, so a template written against
that integration keeps reading the same field:
```yaml
- action: nvidia_shield.adb_command
target: {entity_id: media_player.shield}
data: {command: "getprop ro.product.model"}
response_variable: adb
- action: notify.persistent_notification
data:
message: "{{ adb.adb_response }}"
```
A caller that does not ask for a response is unchanged (the response is
*optional*, not required). If ADB is down the action raises `adb_unavailable` —
an error, never an empty response; a transport failure mid-command propagates as
an error too.
This is the same security posture as the Home Assistant core `androidtv`
integration: a documented, opt-in escape hatch for debugging and automation,
registered at setup and unregistered when the last participating entity unloads.
### Intents (buttons and the `send_intent` action)
An **intent** is one thing an app can be asked to do: open a screen, follow a
deep link, handle an action. This integration carries them over ADB, in two
halves.
**Discovery** asks the Shield what ONE app advertises. The **Scan an app's
intents** row on the Intents screen runs it for the app you pick: the resolver
section of that app's `pm dump` is filtered *on the device* (the whole dump is
~2241 lines), only the section crosses the connection, and the answer is cached
per package for an hour. A scan never registers anything — it offers candidates,
and you pick one. A candidate whose path is a *pattern* (`/title.*`) prefills no
URL on purpose: a pattern is not a link you can open.
**Registration** turns a candidate (or something you type) into a stored intent.
Configure → **Intents** → *Add or edit an intent*:
| Field | What it is |
| --- | --- |
| Name | what the button and the action's dropdown call it; unique per entry |
| Package id | the app's Android package id, exactly as the Shield reports it |
| Activity (component) | the component to launch, `pkg/.Activity` — use this **or** the action |
| Action | an Android action such as `android.intent.action.VIEW` |
| Categories | optional, comma separated (`android.intent.category.DEFAULT`) |
| Data (URL template) | optional URL/URI; `{variable}` placeholders are filled from the declared variables |
| Variables (declared fields) | one per line: `key:type[:required][=value]`, where type is `string`, `int`, `bool` or `raw` |
**One button per registered intent.** Adding an intent adds `button.<name>`
pressing it sends the intent with the declared defaults. A declared field that is
`required` with no default makes the press an error naming the field — such an
intent is meant to be called through the action, which is where variables come
from. Removing the intent removes its button (an options change reloads the
entry). The button's attributes show exactly what it will send.
**The action** takes the intent's name and its variables:
```yaml
- action: nvidia_shield.send_intent
target:
entity_id: media_player.shield
data:
intent: "Netflix: open a title"
variables:
id: "80100172"
response_variable: fired # optional: keep the output in a variable
```
- `intent` (**required**) — the registered name, as the Intents screen shows it.
An unknown one raises `unknown_intent` and **lists the known names**.
- `variables` — one value per declared field. A declared field with a default may
be omitted; a `required` one raises `missing_intent_field`, **naming the field**.
A variable the intent never declared is **refused** (`unknown_intent_variable`):
nothing silently travels to the device, and nothing is silently dropped. A value
that cannot become its declared type names the field and the value
(`invalid_intent_field`).
- The **target is required** and resolves exactly like `adb_command`: one Shield
(`no_target` / `unsupported_target` / `ambiguous_target`), never a guess.
- **The device's own output comes back** in the response under `intent_response`
(`am start` prints `Starting: Intent {…}` when it accepted the intent), so a
caller that asks for a response can see what happened.
- **Failures are loud, and the device's words are kept.** A Shield that refuses
the intent raises `intent_failed` carrying *its* message ("unable to resolve
Intent", "does not exist", "Permission Denial") — never a silent success. ADB
being off, unreachable, or answering nothing raises `intents_need_adb` /
`intent_no_answer`: the intents feature **requires ADB**, because the remote
protocol cannot carry an intent, and the error says so instead of failing
obscurely.
**An intent the app rejects is a configuration problem.** The app may no longer
handle that action, the component may have been renamed by an update, or the
intent may need a permission it does not have. The error shows the Shield's own
message and the fix is the registration — the integration never retries, guesses
or falls back to another intent.
**How the command is built (and why it is safe).** `am start -n <component>
-a <action> -c <category> -d <data> --es/--ei/--ez key value`, assembled from the
stored fields. `{placeholder}` substitution is plain text replacement — **no
`eval`, no `format()`, no shell interpolation** — and every value is quoted, so a
variable whose value is `"; rm -rf /"` reaches the Shield as literal data inside
one argument. This is deliberately **not** a second raw-shell door:
`nvidia_shield.adb_command` remains that one, and this action can only ever send
an intent you registered.
### Screen capture (opt-in, manual)
The integration can expose an `image` entity showing the Shield's current screen —
`sensor`-free and **off by default**. Turn it on in **Configure → Main setup →
"Add a screen capture image entity"**; the entity appears as
`image.<entry>_screen_capture` on the existing device.
Three things about it are deliberate, and they are the whole design:
- **It never drives state.** An `image` entity's state is the timestamp of its last
successful capture. Nothing about a capture writes to the media player, the
sensors, the power switch or the manager's store — the capture path reads the ADB
poller and nothing else. Your media player keeps saying what the DEVICE reported,
not what a screenshot happened to show.
- **It never polls.** HA sets `should_poll = False` for image entities and this one
keeps it: no scan interval, no timer. A capture happens only when something asks
for the bytes (the dashboard showing the entity picture) or when you call
`homeassistant.update_entity` on it. Back-to-back requests are coalesced into one
capture and a successful capture is reused for 5 seconds, so a dashboard repaint
cannot hammer the Shield.
- **What it costs.** Every fresh capture is a full-resolution screen grab
(`screencap -p`) of the current display, base64-encoded, pulled over the same ADB
socket the state polling uses, and it keeps that socket busy for the duration. On
a 1080p UI that is typically a few hundred kilobytes of PNG (roughly a third more
on the wire because of the base64). Treat it as a diagnostic surface you look at
when something looks wrong, not as a camera.
It is unavailable — never a blank image — whenever ADB is off, ADB is down, or the
last capture failed; the `capture_status` attribute says which (`ok`,
`adb_disabled`, `adb_disconnected`, `capture_failed`). An explicit
`homeassistant.update_entity` retries after a failure.
**How it captures, and why that matters.** The transport is `screencap -p |
base64` over the existing shell, decoded locally with `base64.b64decode` and
checked for the PNG signature and the `IHDR` chunk before anything is handed to HA.
The alternative — writing a temp PNG on the device and pulling it as a file — needs
the raw ADB device object and writes to the Shield's shared storage, where a crash
between write and delete leaves a screenshot behind; the base64 route leaves no
device-side artifact at all. The cost is the extra ~33% on the wire, and one thing
is **inferred rather than measured**: no Shield was available in the development
environment, so how the persistent socket copes with a few hundred kilobytes in one
`shell()` call has not been observed on hardware. The decode, the failure paths and
the entity contract are covered by tests, including a real-HA run that drives HA's
own `ImageEntity` and content-type validator.
## Replacing androidtv / androidtv_remote + the glue
This fork replaces the old `androidtv` media player, the `androidtv_remote`
integration and the surrounding YAML glue — there is nothing to keep alongside
it: the entry's own two transports cover the whole device.
1. **Back up** your `configuration.yaml` and note the Shield's IP address.
2. **Remove the old platform YAML** — the `media_player:` blocks using
`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` / `buffering` / `idle` / `off`, plus `on` only when no source reported a power or playback fact) |
| `androidtv` app name / `app_id` lookups | the manager's store + the one app-name chain: your apps map, then the device's own label for the app on screen (fetched on demand; a failed fetch is retried on the next poll instead of being cached as a permanent miss), then the built-in app names, then the package id |
| `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 +464,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 +489,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
@@ -202,19 +502,542 @@ helper the flow calls is a coroutine and its dedup only runs when awaited.
### Options ### Options
The Configure page is a **menu of sub-screens**, not one long form. Home Assistant
renders it as a list of rows; each row opens a short form and comes back to the menu,
and only **Save and close** commits:
| Row | What it holds |
| --- | --- |
| **Main setup** | Shield address, ADB port, `Read state over ADB`, `Add the Nvidia Remote Protocol`, `Show title and artist`, `Allow the adb_command action`, `Add a screen capture image entity` (off by default — see [Screen capture](#screen-capture-opt-in-manual)), Jellyfin player, power switch |
| **Receiver** | the receiver/soundbar entity and the input it uses |
| **Apps** | the apps editor — add, edit or remove one app, or paste the whole list (see below) |
| **Scripts** | one optional script per command, plus the remote-entity script, the channel digit delay and send-ENTER |
| **Save and close** | writes the merged options and reloads the integration |
Every field that used to be on the single page is still there, under one of those
rows. Edits are merged **per key**, so visiting another sub-screen (or saving from a
later one) never drops what an earlier one changed. Half-finished pages are not
written: closing the dialog without **Save and close** leaves the entry as it was.
The **setup** flow follows the same shape: its first form is unchanged (name, host,
transports, the linked entities), and after it the flow is a menu — **Receiver**,
**Connection**, **Set up ADB**, **Pair the remote protocol**, **Create the entry** — so
anything you want to correct is one row away instead of buried in a form you have
already submitted. The two pairing rows appear only while that transport still needs
pairing (a skip leaves its row there to retry; a successful pairing removes it), and
the menu's description names what is still to pair.
Turning **Add the Nvidia Remote Protocol** on in the **Main setup** row of the
*options* page does not silently enable a client that cannot connect: an entry that has
never completed a pairing is taken to the same **Pair the remote protocol** step, and
its **Continue without pairing** escape turns the option back off. Pairing is remembered
per entry, so switching the transport off and on again on a paired entry asks for no new
PIN (the certificate from the first pairing is reused — see
[is my transport paired?](#is-my-protocol-transport-paired-connected-or-not-running-at-all)).
### The Apps table
The **Apps** row opens a small editor rather than one long form. Home Assistant has no
*table* selector, so a table is edited the way HA edits one — **one row per step**:
| Row | What it does |
| --- | --- |
| **Edit the whole list as text** | the multiline field below: paste, rename or delete rows in one go |
| **Add an app** | package id, name to show, and an optional icon |
| **Edit an app** | pick one of the apps the Shield reported, or one you saved; its name and icon open prefilled |
| **Remove an app** | pick a saved row, then confirm it on the next step |
| **Back to the main menu** | leave the editor (nothing is written until **Save and close**) |
*Edit* and *Remove* are offered only while there is something to act on: a table with
no remembered rows and no discovery yet shows just the text field and *Add an app*. The
editor's description lists every app it knows — the rows you saved, and the ones
discovery found, marked `[detected]`. **Edit** acts on both (that is the point: an app
the device does not name reads as `Your Name (package id)` when the map or the built-in
app names know it, and as `package id (no name yet)` when nothing does — picking it is how
you give it a name, and saving stores it as one of your own rows). **Remove** acts only on
rows you saved, because a detected app has no line to delete (it would simply come back
with the next sweep).
A discovered app that nothing can name is **never pre-filled into the text field** as
`package=package`: a row like that would store the package id as if it were a name. It is
offered in the row editor instead, where "no name yet" says what it is.
Removing a row never uninstalls anything from the Shield — the app simply stops being
offered. Rows you do not touch are left exactly as they were.
The text field is one app per line, in the same format the integration stores:
```
ar.tvplayer.tv=TiviMate
com.plex.hdx=Plex|http://192.168.1.10:9000/plex.png
com.netflix.ninja=Netflix
```
`package=name` is enough; an optional `|icon` after the name adds a picture (a URL, or
a `camera.*` entity id — see *Sources* below). The field opens carrying the current
rows, and it is a **multiline** input: earlier versions rendered it as a single-line
string, so the whole table arrived as one flattened line with nowhere to add a row.
A blank field clears the table (and the integration falls back to its built-in app
list). A malformed row — no `=`, an empty name, a space in the package id or in the
icon — is rejected with an error on the page rather than being half-saved, and the
*Add* and *Edit* forms enforce exactly the same rule.
A receiver may be a **`switch.*`** as well as a `media_player.*`: a soundbar or
speaker that only knows on and off is a valid receiver. Playback then **ensures
that switch is on**: `turn_on`, `play_media` and `select_source` call
`switch.turn_on` when — and only when — it reports `off`, and touch nothing else
about it (never volume, never a toggle). An already-on switch gets no call at all,
a switch whose state cannot be read (`unavailable`/`unknown`) is left alone rather
than guessed at, and a receiver that is off while the Shield is idle stays off.
That is what makes receiver **volume scripts unnecessary** for this shape: there is
no volume to script, only an on/off state to ensure. A `media_player.*` receiver
keeps its own behaviour — input selection and attribute enrichment.
- **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; `media_player` or `switch`
- **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.
### App discovery (what is actually installed)
The Configure page can ask the Shield which apps it has, so the **Apps** table does not
start from the built-in app names alone. Pick **Discover installed apps** on the Configure
menu and the table is rebuilt from two ADB queries — and the row then **says what happened**
(see *What the Discover row tells you* below), because an action that appears to do nothing
is indistinguishable from a broken integration:
| Command | What it returns | How it is treated |
| --- | --- | --- |
| `pm list packages -3` | every **third-party** (user-installed) package, one `package:<id>` per line | all of them become rows |
| `cmd package resolve-activity --brief -c android.intent.category.HOME` | the **active launcher** (`com.google.android.tvlauncher/…MainActivity`) | its package is appended |
System apps are **not** enumerated — a Shield ships a few hundred of them and they are
not launchable apps — with the one exception that matters: the active launcher, which
is a system app yet is exactly what the user sees. Nothing else is filtered, so an app
is missing only if Android itself does not report it.
*(Every output shape above is the **documented** Android one, not an observed
capture: no real Shield was available when this was written, so
`pm list packages -3`, `resolve-activity --brief` and `pm dump` are exercised from
fixtures — the same "inferred, not observed" mark the state=6 buffering value
carries in the source ladder above.)*
Names come from the same cached path the Current App sensor uses, and there is ONE
chain behind every surface — this table, `media_player.app_name`/`source`, the Current
App sensor and the Apps editor. In order, the first answer wins:
1. **your Apps map** — your own edit always wins;
2. **the device's own label** for that app, read from `pm dump <package>`: the probe
greps **on the device** for the label shapes Android prints — `application-label:`,
`nonLocalizedLabel=` and `labelRes=` — so only those lines cross ADB (a real dump on
one reported Shield is ~2241 lines of dumpsys, and the old `head -300` cut it before
the package section even began, which is why it looked like "no labels");
a quoted value counts, `null` and a resource id like `0x7f0f0d31` do not;
3. **the built-in app names** (see *Sources* below) — a curated, community-maintained
`package → name` table;
4. **the package id**, shown **once** (never as `pkg=pkg` in a row).
A label probe is cached for 24 h, and its outcome is remembered either way: a probe that
**ran** and found nothing is a definitive negative (zero further round trips for that
app), while a probe that **could not run** is not cached at all and is retried on the next
poll — this is the same rule as the entity path, and it is why a transient ADB hiccup no
longer sticks.
A full sweep runs a handful of probes at a time (4 at a time, 50 ms apart), which
finishes a typical Shield in seconds instead of the minutes a strictly serialised sweep
would take; the commands share the watchdog's own ADB connection, and `adb-shell`
serialises the I/O on it, so the burst queues politely instead of interleaving on the
socket.
**Some devices expose no labels at all.** The reported Shield is one: nothing in that
2241-line dump names any app. So after 5 probes in a row come back "ran, no label" the
integration stops spending a shell per package for the rest of the 24 h TTL: the sweep
answers the remaining packages from the map instead of probing them, names come from
tiers 1, 3 and 4, and a later device change or update is re-checked when the TTL
expires. The Configure menu says so in its verdict when it happens, and the table's
`name` is then your map's name, the built-in name, or **empty** — an app nothing can name
keeps the id in `package` alone rather than inventing a name out of it.
An app that nothing names is still offered by the **Apps** row editor (it reads
"`com.example.app` (no name yet)"), which is where you give it one.
Icons are **best effort and explicitly heuristic**: `pm dump` does not expose an app's
icon resource (an extracted APK's icon is a bitmap only its own code can resolve), so
there is no device icon to read. Each app gets a curated `package → mdi:` icon where we
know one — Netflix, YouTube, Kodi, Plex, Spotify, the launcher, TiviMate, Wholphin —
and `mdi:television` otherwise. The icon never enters your **icon column**: that column
is a picture URL or a `camera.*` entity and feeds `entity_picture`, so writing an
`mdi:` name there would put a value the media player cannot use into your own map.
Discovery does not run on the watchdog: the Configure row runs it, the dashboard
sensor refreshes the table when it has no fresh one, and the result is cached for 6 h.
With ADB off, or unreachable, the table simply shows the rows you wrote yourself and
the menu says why. A sweep that **could not run** is not cached as an answer: it is
recorded as `unknown` and retried, so a Shield that was asleep at startup is picked up
by the next refresh instead of reporting an empty device for six hours.
#### What the Discover row tells you
The Discover row is an *action*: it runs a sweep and returns you to the same menu with a
one-line verdict, which replaces the usual "not discovered yet" note:
| Verdict | What it means |
| --- | --- |
| `N apps found on the Shield — the table is updated.` | the sweep ran, and the editor's text field now carries the discovered rows |
| `the Shield reported no installed apps — the table keeps your saved rows.` | the sweep ran and found nothing to add; your rows are untouched |
| `N apps found — this Shield exposes no app labels over ADB, so names come from the built-in app names and your own rows.` | the sweep ran, and the device itself named nothing: the per-package probes have stopped (see *App discovery* above) |
| `the discovery sweep could not reach the Shield — check the ADB connection; your saved rows are unchanged.` | the sweep failed; nothing was cached as a result and nothing you wrote was lost |
| `ADB is not set up for this entry — nothing could be discovered.` | this entry has no ADB manager (a remote-protocol-only setup), so there is nothing to sweep |
The action is best-effort by design: whatever the verdict, the Configure menu still opens
and the editor still works on the rows you wrote.
**For dashboards** the same table is a sensor: `sensor.<name>_installed_apps`, whose
state is the app **count** and whose `installed_apps` attribute is the list of
`{package, name, icon}`. Its `discovery` attribute says `ok`, `pending`, `unknown`,
`not_connected` or `adb_disabled`: `ok` means the table IS the answer — including a
genuinely empty one — and `unknown` means the last sweep could not run (an empty list
then says nothing about the device, and the next refresh retries). It only exists for
ADB-enabled entries, and it is cheap to refresh — `homeassistant.update_entity` on it
re-runs the sweep only when there is no fresh table (the cache expired, or the last
sweep could not run), while the Configure row forces one.
```yaml
# every installed app, with the name this integration would show
{{ state_attr('sensor.living_room_shield_installed_apps', 'installed_apps') }}
| map(attribute='name') | list }}
```
### Sources (the app list in a dropdown)
The media player's source list is your **Apps** map — the same map app-name resolution
uses. With no map configured it falls back to the built-in app names, which on a device
that exposes no labels *is* the naming: the table covers the apps the reported Shield has
(Projectivy Launcher, Samba (SMB), X-plore, Plex Media Server, TiviMate, Hyperion Grabber,
Prime Video, MediaSession2MQTT, Speech Services by Google, Button Mapper, Wholphin,
Findroid, Pluto TV, YouTube, Downloader, TV Quick Actions, Argosy, Netflix, RetroArch and
the Google TV launcher, plus Google's speech services), so a card shows
"Netflix" instead of `com.netflix.ninja` with nothing configured at all.
That table is a **community-maintained convenience, not device truth**: package ids are
stable, but display names change with vendors and app updates, and an app it has never
heard of falls through to its package id. Your own map overrides any row in it — one line
in **Apps** — and discovery fills the **Configure** table, not this fallback, so the
dropdown stays exactly what you saved. An app that discovery found and you want in the
dropdown is one row to add.
**Apps are sources, not browser media.** This integration no longer publishes the app
directory through HA's media **Browser**: the Browse dialog shows an **empty tree** for
this entity (the device name, no rows) instead of a second, inconsistent copy of the
same list — the user report was *"Media Browser shows Apps. To me it should probably be
integrated with the home assistant media display, keep select sources for apps"*.
Everything an app row could do lives in `source_list` and `media_player.select_source`
below, and nothing else on this entity is browsable, so there is no dead entry to click
and nothing else the browser could 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; with no map, the built-in
app names above.
- **`source`** — the app currently on screen, resolved by the same chain the Current App
sensor uses: your map first, then the cached device label, then the built-in app name,
then the raw package id.
- **`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
holds the package id), 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: `media_player.select_source` accepts its own package id.
- A **padded value** typed by hand (`com.x= Netflix `) is shown cleaned in the
dropdown, 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 the rest of the chain instead: the device
label, then the built-in app name, then the raw package id.
### 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.
### The remote entity: the app / activity picker
The `remote` entity speaks Home Assistant's own remote contract, matching the
`androidtv_remote` entity it replaces — so a replaced setup keeps its automations
**and** gets the app picker the frontend only renders for a remote that declares it:
| Piece | Value |
| --- | --- |
| `supported_features` | includes `RemoteEntityFeature.ACTIVITY` — without that flag Home Assistant offers **no activity picker at all**, which is why the remote card can look empty |
| `activity_list` | the friendly app names, in your order: your **Apps table** (or the built-in table when you have none), plus the apps **ADB discovery** actually found on the device |
| `current_activity` | the app on screen as a friendly name: the protocol's current-app push when it arrives, the ADB foreground reading otherwise. `None` while nothing is known — no activity is ever invented |
| `turn_on` with `activity:` | wakes the Shield when it reports itself off, then launches that app over the protocol |
```yaml
# launch Netflix by the name from the activity list / Apps table
action: remote.turn_on
target:
entity_id: remote.living_room
data:
activity: Netflix
```
An `activity` resolves in this order: a **name** from the activity list
(case-insensitive), then a **package id** or a **detail link** (`https://…`,
`market://…`) passed through unchanged — that is how an app your table does not carry
is still launchable, and what the other integration does with a name it does not know.
Anything else raises a clear error naming the value instead of silently doing nothing.
Leaving `activity` out (or empty) keeps the old behaviour exactly: wake through the
configured script, else `POWER` when the device reports it is off.
**Where the list comes from, and why.** `androidtvremote2` exposes **no app-list API
at all** (measured against the installed library) — which is exactly why
`androidtv_remote` keeps a user-maintained app map. This integration has the same kind
of source *plus* something the other one lacks: ADB discovery, so the picker can offer
what is actually installed and name it through the one chain (your Apps table → the
device's own label → the built-in table → the package id). Apps nothing can name are
not listed as bare package ids; pass their id to `activity` if you need them.
#### Is my protocol transport paired, connected, or not running at all?
The library offers no `is_paired` / `is_connected`, so the remote entity publishes what
can be **proven** (Developer Tools > States, or
`state_attr('remote.living_room', 'protocol_state')`):
| Attribute | Meaning |
| --- | --- |
| `protocol_state` | `off` (no transport configured), `never_connected`, `connected`, `disconnected`, or `auth_rejected` |
| `protocol_connected` | `true` only while the device reports the protocol session up |
| `protocol_ever_authenticated` | `true` once a session connected **or any push arrived** — the only proof of a paired client the library gives |
| `protocol_current_app_seen` / `protocol_is_on_seen` / `protocol_volume_seen` | whether each device push has **ever** arrived (sticky: the state cache expires, these do not) |
| `protocol_last_error` / `protocol_failures` | the last protocol connect/send failure verbatim, and how many sends could **not** run |
| `protocol_certificate` | `present` / `missing` — the certificate from your setup pairing, which is why enabling the transport on a paired entry asks for **no new PIN** |
| `power_on_route` / `power_off_route` | `protocol`, or `script:<script>` when a command script owns that command |
| `activity_source` (and the media player's `app_source`) | which tier produced the app identity: `protocol` (the device's own push) or `adb` (the dumpsys parse) — the push is preferred, and it is never a silent override |
Reading it:
* **`auth_rejected`** — the device refused our certificate (a factory reset, or a
deleted `.storage/nvidia_shield/client.pem`). Home Assistant also flags the entry
for reauthentication; that flow re-pairs.
* **`never_connected`** while the transport is enabled — switched on but with no
session. Pairing again is the answer (the options flow routes an unpaired entry
straight into the pairing step rather than enabling a client that cannot connect).
* **`off`** — the transport is not configured. Everything else here still works: ADB
is the state source, so an ADB-only setup degrades **only** the protocol surface.
* A send that could not run is **never silent**: it is counted, recorded verbatim in
`protocol_last_error`, and logged at WARNING together with `protocol_state`.
**The other Android TV integration.** `androidtv_remote` pairs **its own client** and
shares nothing with us: its certificate lives in its own storage and its entities are
its own. Ours (`client.pem` under `.storage/nvidia_shield/`) is reused between pairing
and runtime by design, so *not* being asked for a PIN on an already-paired entry is
expected — and it says nothing about whether the session is up. `protocol_state`
answers that.
**A configured script bypasses the protocol.** When a command script owns `turn_on`
(or `turn_off`), that script runs and the protocol is **never touched** — so a power
press that worked through it is *not* evidence that the transport is paired, or even
connected (`power_on_route` reads `script:…`). A configured **Remote command script**
is the same story for `remote.send_command`. Only a command that went out over the
protocol, with `protocol_state` = `connected`, exercises the transport.
## Entities ## Entities
| Entity | Type | Description | | Entity | Type | Description |
|--------|------|-------------| |--------|------|-------------|
| Media Player | `media_player` | Main entity with playback controls and metadata | | Media Player | `media_player` | Main entity with playback controls, metadata and the flat `now_playing_*` attributes (below) |
| Current app (`sensor.current_app`) | `sensor` | Currently running app | | Current app (`sensor.current_app`) | `sensor` | Currently running app |
| Now playing (`sensor.now_playing`) | `sensor` | Formatted now-playing string | | Now playing (`sensor.now_playing`) | `sensor` | Formatted now-playing string, with its own flat attributes (below) |
| Remote | `remote` | Key commands, the activity/app picker, and the transport-state attributes (see [the activity picker](#the-remote-entity-the-app--activity-picker)) |
| Pair ADB | `button` | Initiate or re-pair ADB connection | | Pair ADB | `button` | Initiate or re-pair ADB connection |
| Screen capture (`image.screen_capture`) | `image` | The Shield's current screen — **opt-in, off by default**, manual/diagnostic only (see [Screen capture](#screen-capture-opt-in-manual)) |
### Now-playing attributes
**Since t65 the media player's now-playing reading is a set of separate
TOP-LEVEL attributes**, not one nested object — that object read badly in YAML:
| Attribute | What it holds |
| --- | --- |
| `now_playing_via` | which TIER produced the reading: `jellyfin`, `media_session` or `app` |
| `now_playing_state` | the reading's own state — exactly one of `playing`, `paused`, `buffering`, `idle` |
| `now_playing_title` | the title — or, in privacy mode, the app identity |
| `now_playing_artist`, `now_playing_album` | the session's artist and album, when it has them |
| `now_playing_media_type` | the content type the source reported |
| `now_playing_series`, `now_playing_season`, `now_playing_episode` | series metadata, when the source reports it |
| `now_playing_duration`, `now_playing_position`, `now_playing_position_updated_at` | playback timing |
| `now_playing_thumbnail` | the artwork URL (or the Jellyfin entity picture) |
| `now_playing_media_info` | the Jellyfin media-info dict, when that entity has one |
The rules that keep the set predictable:
- **Only what the tier answered is published.** An unknown field is OMITTED, never
`null`, so nothing ever reads as `now_playing_title: null`.
- **A device that reads `off` publishes none of them.** A Jellyfin session that has
gone stale must not put a title beside a device that is off, and the state
already says nothing is playing. `on` (no source reported a power or playback
fact) publishes none either — there is no reading to publish.
- **`now_playing_state` is one of FOUR values, and `on` is not one of them.** The
full set is `playing`, `paused`, `buffering`, `idle` — the states a source can
report when it has something to say. The row is published only while the entity
state is one of those four, so `on` (nothing reported), `off` and any frontend-
or app-side state can never appear beside a title. The list is code
(`NOW_PLAYING_STATE_VALUES` in `media_player.py`) and a test compares THIS
paragraph and the table above against it, so neither can drift.
- **`now_playing_via` is deliberately not `source`.** On a media player, `source`
and `source_list` mean the selected app input (`Netflix`, `TiviMate`, …) and they
still do; naming the tier `source` is exactly what made the reading look like
media metadata from somewhere else. `media_dag_role` keeps its own separate
meaning (that this entity is the "source" role of the media graph).
- **`now_playing_state` IS the entity's own state**, so a dashboard never reads two
different answers: the state ladder is the authority, and the foreground tier's
last-known-playback bridge can still publish a title while the state is already
`idle`.
**Compatibility: `media_dag_now_playing` is GONE** — not deprecated, removed. It
was one object; its contents are the attributes above. No alias is kept: a nested
copy would double every poll's payload, and these attributes are declared
*unrecorded* for exactly that reason (they must not bloat the recorder database). A
template that read the object needs one rename:
```jinja
{# before #}
{{ state_attr('media_player.shield', 'media_dag_now_playing').title }}
{# after #}
{{ state_attr('media_player.shield', 'now_playing_title') }}
```
The Jellyfin-backed `sensor.<entry>_now_playing` keeps its own flat set
(`playback_state`, `title`, `media_type`, `series`, `season`, `episode`,
`duration`, `position`, `thumbnail`), and it follows the same rule: a field the
Jellyfin entity did not answer is omitted, so there are no `title: null` rows
there either. Its `playback_state` reads `idle` whenever that session is not
playing. `sensor.<entry>_current_app` publishes `app_id`/`app_name`, the same
identity the media player carries beside its now-playing attributes.
## Development ## Development
+166 -27
View File
@@ -21,22 +21,42 @@ from homeassistant.core import HomeAssistant
from .adb_session import AdbMediaSessionPoller from .adb_session import AdbMediaSessionPoller
from .connection import ShieldConnectionManager from .connection import ShieldConnectionManager
from .const import ( from .const import (
ADB_COMMAND_SERVICE_DESCRIPTION,
CONF_ADB_ENABLED, CONF_ADB_ENABLED,
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,
CONF_REGISTERED_INTENTS,
DEFAULT_ADB_PORT, DEFAULT_ADB_PORT,
DEFAULT_NAME, DEFAULT_NAME,
DOMAIN, DOMAIN,
ENTRY_VERSION,
SERVICE_ADB_COMMAND, SERVICE_ADB_COMMAND,
SERVICE_SEND_INTENT,
)
from .helpers import (
entry_host,
protocol_cert_paths,
resolve_adb_config,
seed_adb_enabled,
seed_host,
) )
from .helpers import discover_adb_host, protocol_cert_paths, resolve_adb_config
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.SENSOR, Platform.REMOTE, Platform.BUTTON] # t66: IMAGE is listed unconditionally — the platform's async_setup_entry adds
# nothing unless the user opted in, so an install that never turns screen capture
# on pays nothing for the platform existing (and toggling it is an options reload).
PLATFORMS = [
Platform.MEDIA_PLAYER,
Platform.SENSOR,
Platform.REMOTE,
Platform.BUTTON,
Platform.IMAGE,
]
async def async_setup(hass: HomeAssistant, config) -> bool: async def async_setup(hass: HomeAssistant, config) -> bool:
@@ -50,9 +70,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
@@ -63,14 +83,59 @@ async def async_setup(hass: HomeAssistant, config) -> bool:
data=block, data=block,
) )
) )
# t29 G8 (kept here across the t30 splice): the action registers ALWAYS # t29 G8 (kept here across the t30 splice): the action registers ALWAYS.
from .media_player import _ADB_COMMAND_SCHEMA, _async_adb_command_service # The two APIs the target-bearing registration needs are imported WITH that
# registration, next to the handler they belong to (t14): importing the package —
# which every config-flow harness does via
# ``custom_components.nvidia_shield.config_flow`` — must not drag in the service
# registration surface for a domain that harness never sets up.
from homeassistant.core import SupportsResponse
from homeassistant.helpers.service import async_set_service_schema
from .media_player import (
_ADB_COMMAND_SCHEMA,
_INTENT_SCHEMA,
_async_adb_command_service,
_async_send_intent_service,
send_intent_service_description,
)
# t14: the action must be SELECTABLE for the user's Shield. Measured on HA
# 2026.10.1: `async_register` has no `target` parameter, so the UI's target
# picker comes from the service DESCRIPTION — the runtime
# `async_set_service_schema` (which wins over a services.yaml we do not ship)
# carries `target: {entity: [{domain: media_player}]}`, so the picker offers the
# Shield's media player entity. `supports_response=OPTIONAL` is registered FIRST
# because async_set_service_schema reads `hass.services.supports_response()` to
# publish the response flag, and the handler returns the command's output under
# ADB_COMMAND_RESPONSE_KEY.
hass.services.async_register( hass.services.async_register(
DOMAIN, DOMAIN,
SERVICE_ADB_COMMAND, SERVICE_ADB_COMMAND,
_async_adb_command_service, _async_adb_command_service,
schema=_ADB_COMMAND_SCHEMA, schema=_ADB_COMMAND_SCHEMA,
supports_response=SupportsResponse.OPTIONAL,
)
async_set_service_schema(
hass, DOMAIN, SERVICE_ADB_COMMAND, ADB_COMMAND_SERVICE_DESCRIPTION
)
# t17: send_intent — the same measured mechanism as t14 (a runtime
# description carries the target picker, and the response flag is read from
# the registration, so OPTIONAL is registered FIRST). The description is
# republished by every async_setup_entry because the intent DROPDOWN is
# per-entry data while the service is domain-wide; at domain setup no entry
# has loaded yet, so the list is empty and every name is still typeable
# (`custom_value`), with the call-time lookup as the authority.
hass.services.async_register(
DOMAIN,
SERVICE_SEND_INTENT,
_async_send_intent_service,
schema=_INTENT_SCHEMA,
supports_response=SupportsResponse.OPTIONAL,
)
async_set_service_schema(
hass, DOMAIN, SERVICE_SEND_INTENT, send_intent_service_description(())
) )
return True return True
@@ -84,18 +149,21 @@ 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"])
# t17: the intents block travels VERBATIM to the import flow, which owns
# the validation (and drops unusable rows with a log line) — the seed
# normaliser here only decides which keys reach the flow at all.
if isinstance(block.get(CONF_REGISTERED_INTENTS), list):
seed[CONF_REGISTERED_INTENTS] = block[CONF_REGISTERED_INTENTS]
blocks.append(seed) blocks.append(seed)
return blocks return blocks
@@ -172,21 +240,53 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# t17: refresh the send_intent dropdown from what is ACTUALLY loaded. This
# runs on every reload, and an options change reloads the entry, so adding
# or removing an intent updates the picker. The union across entries is
# deliberate: the service is domain-wide and any of them may be the target.
_publish_send_intent_description(hass)
return True return True
def _publish_send_intent_description(hass: HomeAssistant) -> None:
"""Republish send_intent's description with the loaded intent names (t17).
Reads each loaded entry's OPTIONS through the runtime's own view of them
(``registered_intents``), so a hand-edited or malformed row cannot reach the
picker — the dropdown offers exactly what the action would accept.
"""
try:
from homeassistant.helpers.service import async_set_service_schema
from .media_player import registered_intents, send_intent_service_description
except ImportError: # pragma: no cover - a harness without HA's service module
# The dropdown is a RENDERING nicety: the action itself is registered by
# async_setup and works without it. Only a stub harness (which loads this
# module without the real homeassistant package) can get here.
return
names: list[str] = []
for loaded in (hass.data.get(DOMAIN) or {}).values():
options = getattr(loaded, "options", None)
if options is None:
options = getattr(getattr(loaded, "entry", None), "options", None)
names.extend(intent["name"] for intent in registered_intents(options or {}))
async_set_service_schema(
hass, DOMAIN, SERVICE_SEND_INTENT, send_intent_service_description(names)
)
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 +305,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 < 2: if config_entry.version >= ENTRY_VERSION:
return True
options = {**config_entry.options} options = {**config_entry.options}
data = {**config_entry.data}
if config_entry.version < 2:
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
+92 -7
View File
@@ -11,13 +11,16 @@ 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
from .media_player import (
intent_default_variables,
registered_intents,
run_registered_intent,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -27,8 +30,18 @@ async def async_setup_entry(
entry: ConfigEntry, entry: ConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
entities: list[ButtonEntity] = []
if entry.options.get(CONF_ADB_ENABLED, False): if entry.options.get(CONF_ADB_ENABLED, False):
async_add_entities([NvidiaShieldPairAdbButton(hass, entry)]) entities.append(NvidiaShieldPairAdbButton(hass, entry))
# t17: one button per REGISTERED intent. The registration IS the opt-in (the
# plan: nothing registered ever runs by itself), so a new intent adds a
# button and a removed one takes its button away — the options flow reloads
# the entry on every save, which re-runs this setup.
manager = getattr(entry, "runtime_data", None)
for intent in registered_intents(entry.options):
entities.append(NvidiaShieldIntentButton(hass, entry, manager, intent))
if entities:
async_add_entities(entities)
class NvidiaShieldPairAdbButton(ButtonEntity): class NvidiaShieldPairAdbButton(ButtonEntity):
@@ -55,9 +68,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:
@@ -87,3 +100,75 @@ class NvidiaShieldPairAdbButton(ButtonEntity):
host, host,
) )
await poller.disconnect() await poller.disconnect()
class NvidiaShieldIntentButton(ButtonEntity):
"""One button per REGISTERED intent (t17).
The whole point for an intent with no variables: pressing it sends the intent
with the fields' declared defaults. It deliberately does NOT cache a command
— :func:`run_registered_intent` builds it every press, so the button, the
action and the tests all exercise one code path.
Failures are LOUD. A missing required field, ADB being off/down, a transport
that never answered and a device that refused the intent all raise with the
reason (the device's own message included) — a button that silently did
nothing is exactly the "silent success" the plan forbids. An app that
rejected the intent is a CONFIGURATION problem, and the raised text says so.
"""
_attr_has_entity_name = True
def __init__(
self, hass: HomeAssistant, entry: ConfigEntry, manager, intent: dict
) -> None:
self.hass = hass
self._entry = entry
self._manager = manager
self._intent = intent
self._attr_name = intent["name"]
# the intent id is stable across renames, so a rename does not orphan the
# entity (and does not create a second one)
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_intent_{intent['id']}"
self._attr_icon = "mdi:gesture-tap-button"
@property
def device_info(self) -> DeviceInfo:
return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)},
name=self._entry.title,
manufacturer="NVIDIA",
model="Shield TV",
)
@property
def extra_state_attributes(self) -> dict:
"""What this button will send — the registration, readable (t17).
A button whose registration the user cannot see is a black box: these are
the SAME values the command is built from, and package/action/component
are what they typed on the Intents screen.
"""
return {
"intent_id": self._intent["id"],
"package": self._intent["package"],
"activity": self._intent.get("activity") or None,
"action": self._intent.get("action") or None,
"categories": list(self._intent.get("categories") or []),
"data": self._intent.get("data") or None,
"fields": [dict(field) for field in (self._intent.get("fields") or [])],
}
async def async_press(self) -> None:
"""Send the intent with its declared defaults, or raise with the reason."""
entity_id = getattr(self, "entity_id", None) or self._attr_unique_id
variables = intent_default_variables(self._intent)
output = await run_registered_intent(
self._manager, entity_id, self._intent, variables
)
_LOGGER.debug(
"intent button %s sent %s → %s",
entity_id,
self._intent["id"],
(output or "").strip().splitlines()[:1],
)
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+342 -7
View File
@@ -3,7 +3,6 @@
# Re-export shared Media DAG constants # Re-export shared Media DAG constants
from .ha_adb_common.const import ( # noqa: F401 from .ha_adb_common.const import ( # noqa: F401
ATTR_MEDIA_DAG_JELLYFIN_ENTITY, ATTR_MEDIA_DAG_JELLYFIN_ENTITY,
ATTR_MEDIA_DAG_NOW_PLAYING,
ATTR_MEDIA_DAG_RECEIVER_ENTITY, ATTR_MEDIA_DAG_RECEIVER_ENTITY,
ATTR_MEDIA_DAG_RECEIVER_INPUT, ATTR_MEDIA_DAG_RECEIVER_INPUT,
ATTR_MEDIA_DAG_ROLE, ATTR_MEDIA_DAG_ROLE,
@@ -12,6 +11,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,17 +28,24 @@ 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
# user on a form with no way forward. # user on a form with no way forward.
CONF_SKIP_PAIRING = "skip_pairing" CONF_SKIP_PAIRING = "skip_pairing"
#: t15: a PERSISTED marker that this entry's protocol client really paired (a
#: successful ``async_finish_pairing``). Pairing cannot be inferred from anything
#: else: the library exposes no ``is_paired``, and certificate material proves only
#: that SOMETHING generated a key — which an entry that merely enabled the transport
#: without pairing has too. Written by the setup flow's pairing step, the options
#: flow's, and the reauth/reconfigure finishers, so enabling the transport later can
#: tell "already paired" (reuse the certificate, no new PIN) from "never paired"
#: (must pair now, or stay off).
CONF_PROTOCOL_PAIRED = "protocol_paired"
DEFAULT_PROTOCOL_API_PORT = 6466 DEFAULT_PROTOCOL_API_PORT = 6466
DEFAULT_PROTOCOL_PAIR_PORT = 6467 DEFAULT_PROTOCOL_PAIR_PORT = 6467
@@ -45,8 +61,29 @@ ADB_RECONNECT_BACKOFF_CAP = 300 # seconds; backoff never sleeps longer
ADB_RECONNECT_JITTER = 0.25 # ±25 % randomization against reconnect storms ADB_RECONNECT_JITTER = 0.25 # ±25 % randomization against reconnect storms
STATE_STORE_TTL = 60 # ADB-sourced fields older than this are stale STATE_STORE_TTL = 60 # ADB-sourced fields older than this are stale
# t15: the remote-protocol transport's own state machine. The library offers NO
# ``is_paired``/``is_connected`` (measured on androidtvremote2), so these are the
# states we can actually prove — and the one the user was reading as "maybe
# unpaired" was ``never_connected``: keep_reconnecting() alone never connects
# (its loop is ``while self._remote_message_protocol:``, None until a first
# ``async_connect()``), so the transport had never been switched on at all.
PROTOCOL_OFF = "off" # no client wired (disabled/no library)
PROTOCOL_NEVER_CONNECTED = "never_connected" # wired, no session yet, no rejection
PROTOCOL_CONNECTED = "connected" # device reachable at the protocol
PROTOCOL_DISCONNECTED = "disconnected" # session was up, now reported down
PROTOCOL_AUTH_REJECTED = "auth_rejected" # cert refused: pair again (reauth)
#: Seconds allowed for the FIRST protocol connect before it counts as unreachable.
#: Core androidtv_remote uses the same 5 s before deciding ready/not-ready.
PROTOCOL_CONNECT_TIMEOUT = 5.0
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"
@@ -108,11 +145,171 @@ CONF_APP_ICON_MAP = "app_icon_map"
# updates, not per poll). # updates, not per poll).
APP_LABEL_TTL = 24 * 3600 APP_LABEL_TTL = 24 * 3600
# P4-2a adb_command service: rawADB shell on this entry — OFF by default # t57 UNIT 0: exit marker appended to the label dump so a FAILED fetch can be
# told from a package that genuinely has no label. ``adb shell`` output ending
# in this marker means the command ran; an empty answer still carries it, and
# a transport that never delivered output does not. The value is arbitrary but
# unlikely to collide with a manifest line.
APP_LABEL_EXIT_MARKER = "__NSH_EXIT:"
# t66: the label candidates searched FOR ON THE DEVICE. A real `pm dump` on the
# user's Shield is ~2241 lines of dumpsys (package + activity + meminfo +
# procstats + usagestats + batterystats), and the old fetch shipped
# ``| head -300`` — which cut before the package sections even appeared, so the
# negative was a truncation artefact rather than an answer. Searching on-device
# keeps the wire small AND the answer honest: these are the three shapes Android
# prints for an app's label (see ha_adb_common/app_label.py for what counts as a
# name in each).
APP_LABEL_GREP_SHAPES = "application-label|nonLocalizedLabel|labelRes"
# t66: how many label probes may come back "ran, no label" IN A ROW before the
# device is treated as one that exposes no labels at all (this user's Shield).
# One label-less package is ordinary (system/OEM packages often have none), so a
# single miss proves nothing — but a run of them means the whole firmware keeps
# its labels in resources we cannot read, and every further ``pm dump`` per
# package is a shell spent on a question already answered. The conclusion expires
# with APP_LABEL_TTL so a device change is noticed within a day, and any label
# found anywhere resets the run.
APP_LABEL_PROBE_LIMIT = 5
# P4-2a adb_command service: raw ADB shell on this entry — OFF by default
# (same security posture as the core androidtv integration). # (same security posture as the core androidtv integration).
CONF_ADB_COMMAND_ENABLED = "adb_command_enabled" CONF_ADB_COMMAND_ENABLED = "adb_command_enabled"
SERVICE_ADB_COMMAND = "adb_command" SERVICE_ADB_COMMAND = "adb_command"
#: t14: the key the command's output is returned under, in the service RESPONSE.
#: It matches the deprecated ``androidtv.adb_command`` the user fell back to, so a
#: template written against that integration keeps reading the same field.
ADB_COMMAND_RESPONSE_KEY = "adb_response"
#: t14: the picker the UI offers for `nvidia_shield.adb_command`.
#:
#: MEASURED on HA 2026.10.1: ``ServiceRegistry.async_register`` has NO ``target``
#: parameter, so a target cannot be declared at registration. The frontend renders
#: the target picker from the service DESCRIPTION instead (``services.yaml``, or a
#: runtime ``async_set_service_schema`` call, which WINS over services.yaml) — this
#: is that description's ``target``. Only the ENTITY kind is declared: the Shield is
#: selected as its ``media_player`` entity (one per entry), exactly like the
#: deprecated ``androidtv.adb_command`` the user used to fall back to.
#:
#: The entity domain the action's target declares, and the only domain a
#: ``device_id`` expands to (a Shield device also carries sensors, a remote, an
#: image entity — none of those is something to run a raw shell command on).
ADB_COMMAND_TARGET_DOMAIN = "media_player"
ADB_COMMAND_SERVICE_TARGET: dict = {
"entity": [{"domain": ADB_COMMAND_TARGET_DOMAIN}]
}
#: t14: the service DESCRIPTION registered at setup (``async_set_service_schema``).
#:
#: Why a runtime description at all: the description is what the frontend receives
#: from ``get_services``, and a service with no description has no ``target``, which
#: is precisely why the user's UI offered no Shield to select and he had to run his
#: diagnostics through the deprecated integration. The prose here mirrors
#: ``strings.json``/``translations/en.json`` (where HA's translations live) so the
#: form renders even before a translation is loaded; ``tests/test_adb_command_target.py``
#: asserts the two cannot drift.
ADB_COMMAND_SERVICE_DESCRIPTION: dict = {
"name": "ADB command",
"description": (
"Send a raw ADB shell command to the persistent connection of an Nvidia "
"Shield entry (option-gated use)."
),
"target": ADB_COMMAND_SERVICE_TARGET,
"fields": {
"command": {
"name": "Command",
"description": "The shell command to run on the device.",
"required": True,
"selector": {"text": {}},
},
"entity_id": {
"name": "Entity",
"description": (
"The Shield to run it on: its media_player entity. Select it in "
"the target picker above, or pass entity_id (or the Shield's "
"device_id) in YAML."
),
"selector": {"entity": {"domain": "media_player"}},
},
},
}
# ── t17: registered intents (the second half of the intents feature) ──
#
# A registered intent is ordinary OPTIONS data: a list of dicts, one per intent,
# stored under CONF_REGISTERED_INTENTS in JSON-safe form (no sets, no objects).
# It is read by three consumers that must agree about the shape: the options
# screen (which writes it), the button platform (one entity per row) and the
# nvidia_shield.send_intent action (which builds the command). The shape is the
# one docs/INTENTS_PLAN.md §3 fixes:
#
# {id, name, package, activity, action, categories, data, fields}
#
# ``activity`` and ``action`` are alternatives (the plan: a component OR an
# action, optionally with categories), and ``fields`` declares the variables the
# caller may (or must) supply. Only the DATA template carries {placeholders}.
CONF_REGISTERED_INTENTS = "registered_intents"
#: The action that fires a registered intent. Named by the feature, not by the
#: mechanism: the device's own vocabulary is "am start", which the user never
#: types.
SERVICE_SEND_INTENT = "send_intent"
#: t17: the key the device's `am start` output is returned under. Deliberately
#: NOT ``adb_response``: that key belongs to the raw-command action, and a
#: template reading it should not silently start seeing intent output.
INTENT_RESPONSE_KEY = "intent_response"
#: The four field types the plan fixes (v1): string/int/bool map to the device's
#: own flags (``--es``/``--ei``/``--ez``), and ``raw`` passes an extra through
#: verbatim — the documented escape hatch, NOT the default (the plan's §9
#: non-goal is that nothing beyond these four is expressible).
INTENT_FIELD_TYPES = ("string", "int", "bool", "raw")
#: The flag each declared type becomes on ``am start``.
INTENT_TYPE_FLAGS = {
"string": "--es",
"int": "--ei",
"bool": "--ez",
"raw": "--es",
}
#: The explicit "Add new" sentinel of the intents selector. A registered intent
#: id can never collide with it: ids must be slug-shaped (letters, digits,
#: underscore), and this one is not.
INTENT_NEW_SENTINEL = "__new__"
#: The options-flow fields of the per-intent form (one row at a time, the same
#: idiom as the apps editor). The declared fields are edited as TEXT, one
#: ``key:type[:required][=default]`` per line, because that is the shape the user
#: already knows from the apps table and it survives a paste.
INTENT_FIELD_CHOICE = "intent_choice"
INTENT_FIELD_NAME = "intent_name"
INTENT_FIELD_PACKAGE = "intent_package"
INTENT_FIELD_ACTIVITY = "intent_activity"
INTENT_FIELD_ACTION = "intent_action"
INTENT_FIELD_CATEGORIES = "intent_categories"
INTENT_FIELD_DATA = "intent_data"
INTENT_FIELD_DECLARED = "intent_fields"
INTENT_FIELD_CANDIDATE = "intent_candidate"
INTENT_FIELD_SCAN_PACKAGE = "intent_scan_package"
#: How many declared fields one intent may carry, and how many intents an entry
#: may register. Bounded so a hand-edited options blob (or a runaway paste)
#: cannot turn one button press into a command with thousands of flags, and so
#: the options page stays readable.
INTENT_FIELD_LIMIT = 20
INTENT_REGISTRATION_LIMIT = 50
#: The same target picker as adb_command (t14's measured mechanism), reused
#: verbatim: an intent is sent to exactly ONE Shield, selected as its media_player
#: entity, and the runtime description is what renders the picker.
INTENT_SERVICE_TARGET: dict = {
"entity": [{"domain": ADB_COMMAND_TARGET_DOMAIN}]
}
# P5-3 (C-STREAM): live events push — the streaming_shell logcat consumer # P5-3 (C-STREAM): live events push — the streaming_shell logcat consumer
# (foreground AppState fast-path). Default OFF; the user flips it on and # (foreground AppState fast-path). Default OFF; the user flips it on and
# reports stability on their device. # reports stability on their device.
@@ -122,15 +319,79 @@ CONF_LOGCAT_EVENTS = "logcat_events"
# re-stamped (repeat suppression). # re-stamped (repeat suppression).
LOGCAT_SUPPRESS_WINDOW = 15.0 LOGCAT_SUPPRESS_WINDOW = 15.0
# P4-2c BrowseMedia app directory fallback when the options map is empty # P4-2c/t66: the curated package -> name table. It is BOTH the BrowseMedia/source
# (curated; the packages are the plan's reference apps). # directory fallback and the third tier of the app-name chain (after the user's
# own map and a device label, before the raw package id) — on a Shield whose
# `pm dump` carries no label at all it is the answer that makes names work.
#
# It is a community-maintained convenience, NOT device truth: package ids are
# stable, display names change with vendors and app updates, and an app this
# table has never heard of falls back to its package id (shown once). Users
# override any entry in Configure > Apps, and that edit always wins. Adding a row
# is deliberately trivial — keep the ids sorted so the diff stays reviewable.
DEFAULT_APP_DIRECTORY = { DEFAULT_APP_DIRECTORY = {
"ar.tvplayer.tv": "TiviMate", "ar.tvplayer.tv": "TiviMate",
"be.digitalia.mediasession2mqtt": "MediaSession2MQTT",
"com.amazon.amazonvideo.livingroom": "Prime Video",
"com.esaba.downloader": "Downloader",
"com.github.damontecres.wholphin": "Wholphin", "com.github.damontecres.wholphin": "Wholphin",
"com.netflix.ninja": "Netflix", "com.google.android.sambadocumentsprovider": "Samba (SMB)",
"com.google.android.speech.pumpkin": "Google Speech Services",
"com.google.android.tts": "Speech Services by Google",
"com.google.android.tvlauncher": "Google TV launcher",
"com.google.android.youtube.tv": "YouTube", "com.google.android.youtube.tv": "YouTube",
"com.hyperion.grabber": "Hyperion Grabber",
"com.lonelycatgames.Xplore": "X-plore File Manager",
"com.nendo.argosy": "Argosy",
"com.netflix.ninja": "Netflix",
"com.plexapp.mediaserver.smb": "Plex Media Server",
"com.retroarch": "RetroArch",
"com.spocky.projengmenu": "Projectivy Launcher",
"dev.jdtech.jellyfin": "Findroid",
"dev.trooped.tvquickbars": "TV Quick Actions",
"flar2.homebutton": "Button Mapper",
"tv.pluto.android": "Pluto TV",
} }
# t57: best-effort icon per discovered app. `pm dump` does NOT expose the
# app's icon resource (an extracted APK's icon is a bitmap only its own code
# can resolve, and `dumpsys package` prints no drawable reference), so a device
# icon is not obtainable over plain shell. These MDI names are therefore a
# HEURISTIC: a curated package -> MDI mapping with a generic fallback, honest
# about being a guess. The user's own `app_icon_map` (a picture URL or camera
# entity, see CONF_APP_ICON_MAP) always outranks it for entity_picture.
APP_ICON_FALLBACKS = {
"ar.tvplayer.tv": "mdi:television-classic",
"com.github.damontecres.wholphin": "mdi:jellyfish",
"com.netflix.ninja": "mdi:netflix",
"com.google.android.youtube.tv": "mdi:youtube-tv",
"com.google.android.tvlauncher": "mdi:view-dashboard",
"org.xbmc.kodi": "mdi:kodi",
"com.plexapp.android": "mdi:plex",
"com.amazon.amazonvideo.livingroom": "mdi:amazon",
"com.disney.disneyplus": "mdi:movie-open",
"com.spotify.tv.android": "mdi:spotify",
"com.google.android.apps.youtube.music": "mdi:music",
"com.valvesoftware.steamlink": "mdi:gamepad-variant",
"com.spotify.music": "mdi:spotify",
}
#: The fallback for an app this map has never heard of.
APP_ICON_GENERIC = "mdi:television"
# t57: installed-app discovery. The package list changes only when the user
# installs or uninstalls something, so a long TTL is right; refreshing is
# explicit (the options page and the app-list sensor ask for it) rather than a
# per-poll cost.
APP_LIST_TTL = 6 * 3600
#: `pm dump` sweeps for labels run in a small burst — enough to finish a
#: typical Shield quickly, few enough that the label fetches never queue
#: behind each other on one socket or stall the watchdog.
APP_SWEEP_CONCURRENCY = 4
#: Spacing between the fetches of one sweep, per worker. Keeps the burst a
#: trickle on the one socket the watchdog uses, while ~40 apps still finish in
#: a few seconds instead of a few minutes.
APP_SWEEP_SPACING = 0.05
CHANNEL_DIGIT_DELAY = 0.1 CHANNEL_DIGIT_DELAY = 0.1
CONF_CHANNEL_DIGIT_DELAY = "channel_digit_delay" CONF_CHANNEL_DIGIT_DELAY = "channel_digit_delay"
CONF_CHANNEL_SEND_ENTER = "channel_send_enter" CONF_CHANNEL_SEND_ENTER = "channel_send_enter"
@@ -147,3 +408,77 @@ 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"
# t66: the screen-capture image entity — OFF by default. A capture is a
# full-resolution screen grab, and the user's standing constraint is that screen
# capture must never drive state; opt-in keeps a diagnostic surface from costing
# every install a grab per dashboard view.
CONF_SCREEN_CAPTURE_ENABLED = "screen_capture_enabled"
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,
@@ -1,17 +1,53 @@
"""Shared ADB utilities for Home Assistant custom integrations.""" """Shared ADB utilities for Home Assistant custom integrations."""
from .adb_client import AdbClientBase, parse_wakefulness from .adb_client import AdbClientBase, parse_wakefulness
from .app_label import parse_application_label from .app_label import (
parse_app_label,
parse_application_label,
parse_label_res,
parse_non_localized_label,
resolve_package_name,
)
from .const import ( from .const import (
ATTR_MEDIA_DAG_JELLYFIN_ENTITY, ATTR_MEDIA_DAG_JELLYFIN_ENTITY,
ATTR_MEDIA_DAG_NOW_PLAYING,
ATTR_MEDIA_DAG_RECEIVER_ENTITY, ATTR_MEDIA_DAG_RECEIVER_ENTITY,
ATTR_MEDIA_DAG_RECEIVER_INPUT, ATTR_MEDIA_DAG_RECEIVER_INPUT,
ATTR_MEDIA_DAG_ROLE, ATTR_MEDIA_DAG_ROLE,
ATTR_MEDIA_DAG_RUNNING_APPS, ATTR_MEDIA_DAG_RUNNING_APPS,
) )
from .foreground import parse_foreground from .foreground import parse_foreground
from .installed_apps import (
icon_for_package,
installed_packages,
is_launchable,
parse_active_launcher,
parse_package_list,
)
from .intent import PlaybackIntent from .intent import PlaybackIntent
from .intents import (
INTENT_CANDIDATE_LIMIT,
INTENT_ENTRY_CANDIDATES,
INTENT_ENTRY_EMPTY,
INTENT_SCAN_ADB_DISABLED,
INTENT_SCAN_ADB_UNAVAILABLE,
INTENT_SCAN_CACHED,
INTENT_SCAN_FRESH,
INTENT_SCAN_FRESH_EMPTY,
INTENT_SCAN_INVALID_PACKAGE,
INTENT_SCAN_TTL,
INTENT_SCAN_UNREADABLE,
INTERNAL_MARKERS,
IntentCandidate,
IntentScanCache,
IntentScanResult,
activity_resolver_section,
has_activity_resolver_table,
intent_scan_command,
intent_scan_fallback_command,
is_likely_internal,
parse_activity_resolver_table,
parse_intent_report,
)
from .keys import ensure_adb_key from .keys import ensure_adb_key
from .logcat_events import foreground_from_logcat from .logcat_events import foreground_from_logcat
from .media_session import ( from .media_session import (
@@ -24,20 +60,50 @@ from .running_apps import running_apps_from_sessions, running_apps_from_tasks
__all__ = [ __all__ = [
"ATTR_MEDIA_DAG_JELLYFIN_ENTITY", "ATTR_MEDIA_DAG_JELLYFIN_ENTITY",
"ATTR_MEDIA_DAG_NOW_PLAYING",
"ATTR_MEDIA_DAG_RECEIVER_ENTITY", "ATTR_MEDIA_DAG_RECEIVER_ENTITY",
"ATTR_MEDIA_DAG_RECEIVER_INPUT", "ATTR_MEDIA_DAG_RECEIVER_INPUT",
"ATTR_MEDIA_DAG_ROLE", "ATTR_MEDIA_DAG_ROLE",
"ATTR_MEDIA_DAG_RUNNING_APPS", "ATTR_MEDIA_DAG_RUNNING_APPS",
"AdbClientBase", "AdbClientBase",
"INTENT_CANDIDATE_LIMIT",
"INTENT_ENTRY_CANDIDATES",
"INTENT_ENTRY_EMPTY",
"INTENT_SCAN_ADB_DISABLED",
"INTENT_SCAN_ADB_UNAVAILABLE",
"INTENT_SCAN_CACHED",
"INTENT_SCAN_FRESH",
"INTENT_SCAN_FRESH_EMPTY",
"INTENT_SCAN_INVALID_PACKAGE",
"INTENT_SCAN_TTL",
"INTENT_SCAN_UNREADABLE",
"INTERNAL_MARKERS",
"IntentCandidate",
"IntentScanCache",
"IntentScanResult",
"MediaSessionInfo", "MediaSessionInfo",
"PlaybackIntent", "PlaybackIntent",
"activity_resolver_section",
"has_activity_resolver_table",
"intent_scan_command",
"intent_scan_fallback_command",
"is_likely_internal",
"parse_activity_resolver_table",
"parse_intent_report",
"ensure_adb_key", "ensure_adb_key",
"parse_all_media_sessions", "parse_all_media_sessions",
"parse_app_label",
"parse_application_label", "parse_application_label",
"parse_label_res",
"parse_non_localized_label",
"resolve_package_name",
"parse_audio_playback", "parse_audio_playback",
"parse_foreground", "parse_foreground",
"foreground_from_logcat", "foreground_from_logcat",
"icon_for_package",
"installed_packages",
"is_launchable",
"parse_active_launcher",
"parse_package_list",
"parse_wakefulness", "parse_wakefulness",
"parse_media_session", "parse_media_session",
"running_apps_from_sessions", "running_apps_from_sessions",
@@ -1,19 +1,55 @@
"""App friendly-name resolution from the package manifest (P5-1). """App friendly-name resolution from the package dump (P5-1, t66).
Parses `pm dump <package>` output for the ``application-label`` line — the Parses the label candidate LINES of a `pm dump <package>` / `dumpsys package`
default-locale variant; localized suffixed variants answer. Three shapes are searched, in priority order, because Android firmware
(``application-label-<lang>:``) are skipped, and multi-label blocks across prints different ones (and one of them on this user's Shield prints nothing at
locales are tolerated (first default-locale label wins), mirroring the all):
tolerant pattern of the androidtv-adb core helper.
1. ``application-label: 'Netflix'`` — the AAPT-style manifest label, with its
localized suffixed variants (``application-label-<lang>:``) as a fallback.
2. ``nonLocalizedLabel=Netflix`` — the ApplicationInfo field. It is usually
``null`` on a TV device (the label comes from a resource), so a null/empty
value is not a name.
3. ``labelRes=0x7f0f0001`` — a RESOURCE ID, not text. A dump can only name the
app here if it prints a quoted string, so a numeric/hex id is deliberately
NOT used as a name: we would be showing ``0x7f0f0001`` as if it were a name.
Nothing here is a guess about which line exists: every branch is anchored on the
literal prefix Android prints, and a miss returns None so the caller falls
through to the curated map (the answer on a device with no labels at all).
Also exports the ONE package -> name precedence chain, so the media player, the
Current App sensor, the discovery table and the options editor cannot drift:
user map > device label > curated map > raw package id.
""" """
import re import re
#: ``application-label: 'X'`` / ``application-label-de: "X"`` — the AAPT form.
_APPLICATION_LABEL_RE = re.compile( _APPLICATION_LABEL_RE = re.compile(
r"application-label(?:-[A-Za-z_-]+)?:\s*'([^']*)'" r"application-label(?:-[A-Za-z_-]+)?:\s*'([^']*)'"
r'|application-label(?:-[A-Za-z_-]+)?:\s*"([^"]*)"' r'|application-label(?:-[A-Za-z_-]+)?:\s*"([^"]*)"'
) )
#: ``nonLocalizedLabel=X`` — bare word, quoted string, or ``null``.
_NON_LOCALIZED_LABEL_RE = re.compile(
r"nonLocalizedLabel=(?:'([^']*)'|\"([^\"]*)\"|([^\s,}]+))"
)
#: ``labelRes=0x7f0f0001`` — only a QUOTED value is text; an id is not a name.
_LABEL_RES_RE = re.compile(r"labelRes=(?:'([^']*)'|\"([^\"]*)\")")
#: Values a dump prints that are not names.
_NOT_A_NAME = ("", "null", "0x0", "0")
def _clean(value: str | None) -> str | None:
"""A candidate label, or None when it is empty/``null``."""
text = (value or "").strip()
if text.lower() in _NOT_A_NAME:
return None
return text
def parse_application_label(dump: str) -> str | None: def parse_application_label(dump: str) -> str | None:
"""The package's default-locale application label, or None. """The package's default-locale application label, or None.
@@ -24,7 +60,92 @@ def parse_application_label(dump: str) -> str | None:
if not dump: if not dump:
return None return None
for match in _APPLICATION_LABEL_RE.finditer(dump): for match in _APPLICATION_LABEL_RE.finditer(dump):
label = (match.group(1) or match.group(2) or "").strip() label = _clean(match.group(1) or match.group(2))
if label and label not in ("null", ""): if label:
return label return label
return None return None
def parse_non_localized_label(dump: str) -> str | None:
"""The ``nonLocalizedLabel=`` value, or None (t66).
``null`` is the common case on a TV and is NOT a name.
"""
if not dump:
return None
for match in _NON_LOCALIZED_LABEL_RE.finditer(dump):
label = _clean(match.group(1) or match.group(2) or match.group(3))
if label:
return label
return None
def parse_label_res(dump: str) -> str | None:
"""A QUOTED ``labelRes=`` value, or None (t66).
``labelRes=0x7f0f0001`` is a resource id — showing it as an app name would be
worse than showing the package id, so only a quoted string counts.
"""
if not dump:
return None
for match in _LABEL_RES_RE.finditer(dump):
label = _clean(match.group(1) or match.group(2))
if label:
return label
return None
def parse_app_label(dump: str) -> str | None:
"""The best device label in ``dump``, trying every shape Android prints.
Priority follows how specific the shape is: the AAPT manifest label first,
then the ApplicationInfo field, then a quoted resource label. None means the
device did not name this app — which, on a device whose dump carries no
labels at all, is the definitive answer that sends the caller to the curated
map (t66).
"""
return (
parse_application_label(dump)
or parse_non_localized_label(dump)
or parse_label_res(dump)
)
def resolve_package_name(
package: str | None,
*,
user_map=None,
device_label=None,
curated=None,
) -> str | None:
"""THE package -> display-name chain (t66): one order, every surface.
1. the user's explicit apps map (their own edit always wins);
2. the device's own label, when the device exposes one;
3. the curated community map (the answer on a device with no labels: see
``DEFAULT_APP_DIRECTORY``);
4. the raw package id, shown ONCE.
``device_label`` may be a VALUE or a CALLABLE. A callable is looked up only
after the user map has missed, which is what keeps tier 2 LAZY: the manager's
``app_label()`` accessor enqueues an on-demand device fetch on a miss, so
calling it for an app the user has already named would spend a shell to learn
something nobody reads.
Returns None only for an empty package, so a caller can always display
something. The id is never doubled into a ``pkg=pkg`` row: tier 4 IS the id,
and there is no tier that formats it as a name as well.
"""
if not package:
return None
mapped = (user_map or {}).get(package)
if mapped:
return str(mapped)
raw_label = device_label(package) if callable(device_label) else device_label
label = _clean(raw_label)
if label:
return label
curated_name = (curated or {}).get(package)
if curated_name:
return str(curated_name)
return package
@@ -17,9 +17,11 @@ ATTR_MEDIA_DAG_RECEIVER_INPUT = "media_dag_receiver_input"
# Jellyfin media_player entity for rich metadata # Jellyfin media_player entity for rich metadata
ATTR_MEDIA_DAG_JELLYFIN_ENTITY = "media_dag_jellyfin_entity" ATTR_MEDIA_DAG_JELLYFIN_ENTITY = "media_dag_jellyfin_entity"
# Now-playing dict: {state, title, media_type, series?, season?, episode?, # t65 flattened the now-playing reading into separate top-level ``now_playing_*``
# duration?, position?, thumbnail?, artist?, album?, media_info?, source?} # attributes on the media player (``now_playing_via`` names the tier), so the single
ATTR_MEDIA_DAG_NOW_PLAYING = "media_dag_now_playing" # nested ``media_dag_now_playing`` key this file used to define is GONE — the last
# reference to it was removed in t66's cleanup. The flattened names live in
# ``custom_components/nvidia_shield/media_player.py`` (``now_playing_attribute_names``).
# List of running app dicts: [{package, state, title?, artist?}, ...] # List of running app dicts: [{package, state, title?, artist?}, ...]
ATTR_MEDIA_DAG_RUNNING_APPS = "media_dag_running_apps" ATTR_MEDIA_DAG_RUNNING_APPS = "media_dag_running_apps"
@@ -0,0 +1,139 @@
"""Discover the apps actually installed on the Shield (t57).
The live complaint ("it doesn't find all the apps") came from the curated
fallback list: only four packages were ever offered, so a user with Wholphin,
SmartTube, Kodi, Plex or anything else saw nothing. The device knows its own
apps, so this module asks it.
The commands (both plain `pm`, available on Shield Experience 9 / Android 11
with no extra permission beyond the ADB pairing this integration already has):
* ``pm list packages -3`` — third-party (user-installed) packages, one
``package:<id>`` line each. This is what the live complaint is about: an app
the user installed is a ``-3`` package.
* ``cmd package resolve-activity --brief -c android.intent.category.HOME``
— the ACTIVE launcher (``com.google.android.tvlauncher/....MainActivity``).
A launcher is a system app, so it is not in ``-3``, yet it is exactly the app
a user sees and may want to name or launch — so it is added explicitly.
System apps are otherwise NOT enumerated: the Shield ships a few hundred of
them and they are not launchable apps a media-player source list wants. The
one system app that matters (the launcher) is resolved, not filtered.
Labels come from the existing ``pm dump`` + ``parse_application_label`` path and
its 24 h cache (``ShieldConnectionManager.app_label``), so discovery adds no
second naming mechanism.
Icons are best effort and explicitly heuristic — see ``icon_for_package``.
MARK — documented, not observed (t62 nit c): the output SHAPES this module parses
— ``pm list packages -3`` printing ``package:<id>`` lines, and
``resolve-activity --brief`` printing ``package/activity`` — are Android's
documentation, not a captured run. No real Shield was available when discovery was
written (t57), and no fixture in this repo is a device capture of either command
(``tests/fixtures`` holds activity, wakefulness and media-session dumps instead), so
everything below is exercised from the hand-built fixtures in
``tests/test_installed_apps.py``. That is why the parsers are deliberately tolerant:
an output shape we have never seen degrades to an empty list rather than to a wrong
one. Same mark as the state=6 buffering inference — the dataclass note in
``ha_adb_common/media_session.py``, echoed in ``media_player.py`` — and as the
discovery section of ``connection.py``.
"""
from __future__ import annotations
import re
#: One `pm list packages` line: ``package:com.example.app``.
_PACKAGE_LINE_RE = re.compile(r"^package:(?P<package>[\w.]+)\s*$", re.MULTILINE)
#: `resolve-activity --brief` prints ``package/activity`` on the last line,
#: possibly preceded by the (verbose) intent header.
_COMPONENT_RE = re.compile(r"^(?P<package>[\w.]+)/[\w.$]+$", re.MULTILINE)
#: Activity components that belong to a launcher but are not what the user
#: opens: settings/hidden entry points a HOME resolution can land on.
_LAUNCHER_NOISE = ("com.android.tv.settings",)
def parse_package_list(output: str | None) -> list[str]:
"""The package ids in a ``pm list packages`` dump, in device order.
Tolerant on purpose: the documented output (see the module MARK — the shape is
Android's documentation, not a capture) has one ``package:<id>`` per line, and a
shell that prepends noise (adb banners, warnings) must not lose the list.
Duplicates are collapsed, order preserved.
"""
if not output:
return []
seen: set[str] = set()
packages: list[str] = []
for match in _PACKAGE_LINE_RE.finditer(output):
package = match.group("package")
if package not in seen:
seen.add(package)
packages.append(package)
return packages
def parse_active_launcher(output: str | None) -> str | None:
"""The active HOME package from ``cmd package resolve-activity --brief``.
Android prints a verbose intent header and then a single
``package/activity`` line; only the last component-looking line is the
answer. Returns None when nothing resolved (a device with no launcher, or a
command that produced no output) — the caller treats that as "no launcher
to add" rather than as a failure.
"""
if not output:
return None
packages = _COMPONENT_RE.findall(output)
for package in reversed(packages):
if package not in _LAUNCHER_NOISE:
return package
return None
def installed_packages(user_output: str | None, launcher_output: str | None) -> list[str]:
"""The discovered package ids: user apps first, then the active launcher.
The launcher goes last so a source list built from this keeps the user's own
apps at the top, and it is de-duplicated because a launcher CAN be a
third-party package (a replacement TV launcher installed from the store).
"""
packages = parse_package_list(user_output)
launcher = parse_active_launcher(launcher_output)
if launcher and launcher not in packages:
packages.append(launcher)
return packages
def icon_for_package(package: str, curated: dict[str, str] | None = None,
generic: str = "mdi:television") -> str:
"""A best-effort MDI icon for a package, never a non-string (t57).
HEURISTIC, and labelled as such: the icon is looked up in a curated
package -> MDI map, and anything unknown gets the generic fallback. There is
no device query behind this because there cannot be one — ``pm dump`` does
not print the app's icon resource, and resolving an extracted APK's bitmap
would need code the app itself runs. The user's own icon column (a picture
URL or a ``camera.*`` entity) is what ``entity_picture`` consumes, and it
outranks this.
Returns a string in every case: the UI is never handed None or a dict.
"""
if not package:
return generic
table = curated if curated is not None else {}
return table.get(package) or generic
def is_launchable(package: str) -> bool:
"""Cheap sanity filter for a package id.
Not a real Android query: a package id is dotted and non-empty. The filter
exists so a malformed line cannot become a table row or a source entry.
"""
return bool(package) and "." in package and all(
part.isalnum() or part in ("_", "-") for part in package.split(".")
)
@@ -0,0 +1,705 @@
"""Intent discovery from an app's Activity Resolver Table (t16).
The approved intents feature (``docs/INTENTS_PLAN.md`` §2) starts here: the user
scans ONE installed app and we answer with the intents that app advertises. The
source of truth is the ``Activity Resolver Table:`` section Android prints in
``pm dump <package>``.
WHY THE DUMP IS FILTERED ON THE DEVICE. On the user's Shield (captured
2026-10-12) ``pm dump com.netflix.ninja`` is ~2241 lines of WHOLE dumpsys —
``DUMP OF SERVICE package:`` and then activity/meminfo/procstats/usagestats/
batterystats — for ONE app. Shipping that over ADB to read a few dozen resolver
lines is the same mistake ``| head -300`` made for app labels (t66, where the
truncation was misread as "this app has no label"), so the command built here
pipes the dump through ``sed`` ON THE DEVICE and only the resolver section
crosses the wire:
pm dump <pkg> 2>/dev/null \
| sed -n "/Activity Resolver Table/,/Service Resolver Table/p" \
| head -n <cap>
with a bounded ``grep -A … -m 1 -F`` fallback (``intent_scan_fallback_command``)
for a device whose toolbox cannot do sed ranges. The package id is always
``shlex.quote``d — a package name is the only thing in either command that could
carry shell metacharacters, and quoting it is what makes the command safe — and
everything else in the command is a fixed constant from this file.
WHAT THE CANDIDATES CARRY AND WHY. Per §3's data model: component, action,
categories, and the data (scheme, authority, path). A filter that carries several
schemes, authorities or paths is expanded into ONE CANDIDATE PER COMBINATION,
because each of those is a concrete match — a URI the app really accepts — and a
picker row that mixes two schemes cannot be turned into a URI at all. Candidates
are deduplicated on the whole tuple, so the expansion costs nothing when the
app's filters coincide.
WHAT THIS MODULE DOES NOT MODEL: MIME types. The resolver key of a MIME filter
(``video/*:``) is not one of §3's fields, and inventing a data URI out of it
would be worse than reporting the filter through its component, action and
categories — which is what happens. Nothing is dropped for being unmodelled.
MARK — READ THIS BEFORE TRUSTING A SHAPE. The parser is proven against a
TRANSCRIBED capture (``tests/fixtures/pm_dump_netflix_resolver.txt``), not
against a device: no real Shield was available, and the raw capture text is not
in this repository either. The entry/detail shapes below are what the capture
records (see the fixture's own notes for which lines are verbatim and which are
reconstructed), and every parser branch is deliberately tolerant of a shape we
have never seen: an unrecognised line degrades to "no candidate from this line",
never to a wrong candidate.
NOT TO BE CONFUSED WITH ``.intent``: that module holds ``PlaybackIntent``, the
optimistic-UI dataclass, not an Android intent. §7.4 of the plan suggests renaming
it to ``playback_intent.py``; that churn is not part of this unit, so the two names
sit side by side here and this note is the signpost.
"""
from __future__ import annotations
import re
import shlex
import time
from collections.abc import Iterator
from dataclasses import dataclass
#: ``Activity Resolver Table:`` — the section that opens the package's resolver
#: tables, and the two headings that END it on the observed capture (the receiver
#: table follows the activity one, then the service table).
ACTIVITY_TABLE_HEADER = "Activity Resolver Table:"
RECEIVER_TABLE_HEADER = "Receiver Resolver Table:"
SERVICE_TABLE_HEADER = "Service Resolver Table:"
PROVIDER_TABLE_HEADER = "Provider Resolver Table:"
#: The end of the activity section: whichever of these comes FIRST. Stopping at
#: the first one can only ever cut the section short, never extend it into
#: another table (the plan's sed uses the service heading; a capture where the
#: receiver table is absent still ends here).
_SECTION_ENDS = (
RECEIVER_TABLE_HEADER,
SERVICE_TABLE_HEADER,
PROVIDER_TABLE_HEADER,
)
#: The two patterns the ON-DEVICE sed range is built from. The colons are dropped
#: deliberately: the shipped command is the plan's own line
#: (``sed -n '/Activity Resolver Table/,/Service Resolver Table/p'``), which is
#: what has to be recognisable in a shell and in a review, and both patterns match
#: the same printed heading. They are constants — never caller text — so quoting
#: them in the command below cannot be broken from outside.
SED_RANGE_START = "Activity Resolver Table"
SED_RANGE_END = "Service Resolver Table"
#: How long a scan that RAN is reused, per package. An app's advertised intents
#: only change when the app does, so this is a cost bound rather than a staleness
#: guess: one shell an hour for a package the user keeps looking at.
INTENT_SCAN_TTL = 3600.0
#: The transfer bound of both commands (``head -n`` / ``grep -A``). A real
#: resolver section is tens of lines; this is what keeps a pathological table
#: from becoming a wall of text on the wire.
INTENT_SCAN_LINE_LIMIT = 400
#: The cap on returned candidates, applied AFTER dedup and ranking. The cap is
#: disclosed (``IntentParseResult.truncated`` / ``IntentScanResult.truncated``),
#: so a capped answer never looks complete.
INTENT_CANDIDATE_LIMIT = 200
#: One entry line: an optional group key, the component's hex id, the COMPONENT
#: and — for an activity filter — the filter's own id. Both shapes the capture
#: records match, with or without the group key in front:
#:
#: cast: deca7fa com.netflix.ninja/.MainActivity filter fc483a1
#: deca7fa com.netflix.ninja/.MainActivity filter fc483a2
_ENTRY_RE = re.compile(
r"^(?P<key>.*?)\s*(?P<hex_id>[0-9a-fA-F]{6,})\s+"
r"(?P<component>[A-Za-z_][\w.]*/[\w.$]+)"
r"(?:\s+filter\s+(?P<filter_id>[0-9a-fA-F]+))?\s*$"
)
#: A group heading on its own line (``Schemes:``, ``Non-Data Actions:``, and the
#: action-keyed groups inside it). Used for ONE fallback only: when a filter
#: carries no ``Action:`` detail, the key that grouped it is the action the
#: device printed — never a guess about a key that is not action-shaped.
_GROUP_KEY_RE = re.compile(r"^\s+(?P<key>[A-Za-z_][\w.$]*)\s*:\s*$")
#: A filter detail line. The five fields §3's model carries; ``Type:`` (a MIME
#: type) and anything else are read off the line and ignored — see the module
#: docstring on what is not modelled.
_DETAIL_RE = re.compile(
r"^\s*(?P<field>Action|Category|Scheme|Authority|Path)\s*:\s*(?P<value>.+?)\s*$"
)
#: ``Authority: "www.netflix.com": -1`` — the authority, not its weight.
_AUTHORITY_RE = re.compile(r'^"(?P<value>[^"]*)"\s*(?::\s*-?\d+)?$')
#: ``Path: "PatternMatcher{GLOB: /title.*}"`` — the matcher kind and its value.
#: Both separators Android prints are accepted (``GLOB: /x`` and ``LITERAL, /x``).
_PATH_MATCHER_RE = re.compile(
r"^PatternMatcher\{(?P<kind>[A-Za-z_]+)\s*[:,]?\s*(?P<value>.*)\}$"
)
#: The marker families ``docs/INTENTS_PLAN.md`` §2 names as internal-looking.
#: They are FLAGGED and never deleted: the user decides what is worth
#: registering, and a candidate we quietly removed is one they can never
#: register. Matching is on the action and the component (the capture's own
#: noise is action-shaped, e.g. ``com.netflix.action.DEBUG_PUSH_NOTIFICATION``).
INTERNAL_MARKERS = (
"DEBUG_",
"androidx.",
"com.google.firebase.",
"ConstraintProxy",
)
#: A cache entry's state (t16). ONLY a scan that RAN writes an entry, and the
#: entry then says which of these two answers it holds — the distinction the
#: discovery-cache defect taught us, made structural instead of remembered.
INTENT_ENTRY_CANDIDATES = "candidates"
INTENT_ENTRY_EMPTY = "empty"
#: A scan result's status. ``answered`` is True for the first three only: the
#: rest mean "we do not know", which is NOT the same as "this app advertises
#: nothing" and must never be shown as an empty list of intents.
INTENT_SCAN_FRESH = "fresh"
INTENT_SCAN_FRESH_EMPTY = "fresh_empty"
INTENT_SCAN_CACHED = "cached"
INTENT_SCAN_ADB_DISABLED = "adb_disabled"
INTENT_SCAN_ADB_UNAVAILABLE = "adb_unavailable"
INTENT_SCAN_UNREADABLE = "unreadable"
INTENT_SCAN_INVALID_PACKAGE = "invalid_package"
#: The statuses whose candidates are an ANSWER from the device.
_ANSWERED_STATUSES = (
INTENT_SCAN_FRESH,
INTENT_SCAN_FRESH_EMPTY,
INTENT_SCAN_CACHED,
)
def _unquote(value: str | None) -> str:
"""Strip one layer of matching quotes; DROP an unterminated one (t21/F2).
``"x"`` -> ``x``. A value that OPENS a quote and never closes it is not a
value we know: the capture was cut mid-detail (``Scheme: "ht``,
``Authority: "www.exam``, ``Path: "PatternMatcher{GLOB: /title``), and
believing the leftover produced a scheme, host or pattern that LOOKS real
while no app can match it — `_candidate_data` would then prefill the form
with a URL that cannot work. An unterminated value is therefore dropped
(empty here; the callers turn that into None), so a corrupt detail is never
mistaken for a real one. It was never an injection either way (every value is
quoted when it is used), which is exactly why the failure was invisible.
"""
text = (value or "").strip()
if text[:1] in ("'", '"'):
if len(text) >= 2 and text[-1] == text[0]:
return text[1:-1].strip()
return ""
return text
def _authority_value(raw: str) -> str | None:
"""The authority in an ``Authority:`` detail, or None.
Android prints ``Authority: "www.netflix.com": -1`` (quoted, then the filter's
score); a bare ``Authority: www.netflix.com`` is accepted too.
"""
text = (raw or "").strip()
match = _AUTHORITY_RE.match(text)
if match:
return match.group("value") or None
return _unquote(text) or None
def _path_value(raw: str) -> tuple[str | None, str | None]:
"""``(matcher_kind, path)`` from a ``Path:`` detail.
``"PatternMatcher{GLOB: /title.*}"`` -> ``("GLOB", "/title.*")``;
``"PatternMatcher{LITERAL, /browse}"`` -> ``("LITERAL", "/browse")``; anything
that is not a PatternMatcher is returned as a bare path with no kind.
"""
text = _unquote(raw)
match = _PATH_MATCHER_RE.match(text)
if match:
return match.group("kind").upper(), match.group("value").strip()
return None, text or None
def looks_like_action(key: str | None) -> bool:
"""Is a group key an ACTION rather than a sub-heading (t16)?
``android.intent.action.MAIN`` and ``com.netflix.action.DIAL_START`` are
action-shaped; ``Schemes``, ``Non-Data Actions`` and the ``cast:``/``http:``
scheme keys are not. Only used when a filter printed no ``Action:`` detail.
"""
text = (key or "").strip().rstrip(":")
return bool(text) and "." in text and " " not in text
def is_likely_internal(action: str | None, component: str | None = None) -> bool:
"""Does this candidate LOOK like the app's own internal plumbing (t16)?
A heuristic, and labelled as one: the four families the plan names are
matched anywhere in the action or the component, and the flag only moves a
candidate to the END of the ordering (``rank_candidates``) — it never removes
it, because the user is the one who decides what is worth registering.
"""
action_text = action or ""
component_text = component or ""
return any(
marker in action_text or marker in component_text
for marker in INTERNAL_MARKERS
)
@dataclass(frozen=True)
class IntentCandidate:
"""One intent an app advertises, as its resolver table describes it (t16).
``component`` is the ``package/Activity`` the filter belongs to; ``action``
is the intent action (never invented: see ``looks_like_action``); the data
fields are one concrete match, so ``scheme``/``authority``/``path`` together
describe a URI the app accepts. ``path_kind`` is Android's matcher
(``GLOB``/``LITERAL``/``PREFIX``) and is kept because ``/title.*`` is a glob,
not a path: a caller that treats it as literal builds the wrong URI.
"""
component: str
action: str | None = None
categories: tuple[str, ...] = ()
scheme: str | None = None
authority: str | None = None
path: str | None = None
path_kind: str | None = None
likely_internal: bool = False
@property
def key(self) -> tuple:
"""The dedup key: everything the candidate means, nothing about its row."""
return (
self.component,
self.action,
self.categories,
self.scheme,
self.authority,
self.path,
self.path_kind,
)
def as_dict(self) -> dict:
"""A plain dict, for the options page, diagnostics and JSON."""
return {
"component": self.component,
"action": self.action,
"categories": list(self.categories),
"scheme": self.scheme,
"authority": self.authority,
"path": self.path,
"path_kind": self.path_kind,
"likely_internal": self.likely_internal,
}
@dataclass(frozen=True)
class IntentParseResult:
"""What one capture yielded: the candidates, and whether the cap applied."""
candidates: tuple[IntentCandidate, ...] = ()
found: int = 0
truncated: bool = False
def has_activity_resolver_table(output: str | None) -> bool:
"""Does this capture carry the resolver section at all (t16)?
THE distinction this module exists to keep: a capture WITH the heading that
holds no entries is an ANSWER ("this app advertises nothing we can read"),
while a capture WITHOUT it is not an answer at all — the transport handed us
something else (an error line, a truncated answer, nothing). Only the former
may be cached (``IntentScanCache``); the latter must be retried.
"""
return bool(output) and ACTIVITY_TABLE_HEADER in output
def activity_resolver_section(output: str | None) -> str | None:
"""The ``Activity Resolver Table:`` section of a capture, or None.
Bounded by the FIRST of the receiver/service/provider headings, so a capture
that carries the whole dumpsys cannot leak another table's filters (a
receiver's actions are not an activity's intents). None when the section is
absent — see ``has_activity_resolver_table`` for why that is not "no intents".
"""
if not output:
return None
start = output.find(ACTIVITY_TABLE_HEADER)
if start < 0:
return None
body = output[start + len(ACTIVITY_TABLE_HEADER):]
end = len(body)
for heading in _SECTION_ENDS:
found = body.find(heading)
if 0 <= found < end:
end = found
return body[:end]
@dataclass
class _Entry:
"""A filter line being read: its component, its key, its own filter id."""
component: str
key: str | None = None
filter_id: str | None = None
@property
def group_action(self) -> str | None:
"""The group key as an action, when it is action-shaped (else None)."""
key = self.key
return key.rstrip(":") if looks_like_action(key) else None
def _iter_filters(section: str) -> Iterator[tuple[_Entry, list[tuple[str, str]]]]:
"""Yield ``(entry, details)`` for every filter line in the section.
Details belong to the filter line they follow, so the walk is line-order and
resets on each new entry. A group key on its own line is remembered for the
action fallback (``_Entry.group_action``).
"""
entry: _Entry | None = None
details: list[tuple[str, str]] = []
group_key: str | None = None
for line in section.splitlines():
match = _ENTRY_RE.match(line)
if match:
if entry is not None:
yield entry, details
key = (match.group("key") or "").strip() or group_key
entry = _Entry(
component=match.group("component"),
key=key,
filter_id=match.group("filter_id"),
)
details = []
continue
detail = _DETAIL_RE.match(line)
if detail is not None:
if entry is not None:
details.append((detail.group("field"), detail.group("value")))
continue
heading = _GROUP_KEY_RE.match(line)
if heading is not None:
group_key = heading.group("key")
if entry is not None:
yield entry, details
def _data_specs(details: list[tuple[str, str]]) -> list[tuple]:
"""The ``(scheme, authority, path, path_kind)`` combinations of one filter.
``Scheme:`` opens a group; the ``Authority:``/``Path:`` lines that follow it
belong to it (that is the nesting the capture prints). A filter with none of
the three yields a single all-None spec: "this filter matches on its action
alone", which is exactly what a Non-Data Action is.
"""
groups: list[list] = []
for field, raw in details:
if field == "Scheme":
groups.append([_unquote(raw) or None, [], []])
continue
if field not in ("Authority", "Path"):
continue
if not groups:
groups.append([None, [], []])
target = groups[-1]
if field == "Authority":
value = _authority_value(raw)
if value:
target[1].append(value)
else:
target[2].append(_path_value(raw))
specs: list[tuple] = []
for scheme, authorities, paths in groups:
for authority in authorities or [None]:
for path_kind, path in paths or [(None, None)]:
specs.append((scheme, authority, path, path_kind))
return specs
def _candidates_for(entry: _Entry, details: list[tuple[str, str]]) -> list[IntentCandidate]:
"""Every candidate one filter line yields (action x data combination)."""
actions: list[str | None] = []
categories: list[str] = []
for field, raw in details:
if field == "Action":
value = _unquote(raw)
if value and value not in actions:
actions.append(value)
elif field == "Category":
value = _unquote(raw)
if value and value not in categories:
categories.append(value)
if not actions:
# No Action: detail. The group key is the action ONLY when it is
# action-shaped; otherwise the candidate keeps action None rather than
# inventing one from a sub-heading.
actions = [entry.group_action]
specs = _data_specs(details) or [(None, None, None, None)]
candidates: list[IntentCandidate] = []
for action in actions:
for scheme, authority, path, path_kind in specs:
candidates.append(
IntentCandidate(
component=entry.component,
action=action,
categories=tuple(categories),
scheme=scheme,
authority=authority,
path=path,
path_kind=path_kind,
likely_internal=is_likely_internal(action, entry.component),
)
)
return candidates
def rank_candidates(
candidates: list[IntentCandidate] | tuple[IntentCandidate, ...],
*,
limit: int = INTENT_CANDIDATE_LIMIT,
) -> list[IntentCandidate]:
"""Deduplicate, order deterministically, and cap (t16).
Order: candidates a user is likely to want first (``likely_internal`` last),
then the dedup key itself — action, scheme, authority, path, component,
categories — so the order is total and identical on a re-scan, which is what
makes it safe for a UI to key rows by position.
The cap (``limit``, default ``INTENT_CANDIDATE_LIMIT``) keeps a pathological
table from becoming a wall of rows; it is applied AFTER dedup, drops the
least interesting rows first (internal-looking ones) and is DISCLOSED by the
caller (``IntentParseResult.truncated``), never silent.
"""
unique: dict[tuple, IntentCandidate] = {}
for candidate in candidates:
unique.setdefault(candidate.key, candidate)
ordered = sorted(
unique.values(),
key=lambda c: (
c.likely_internal,
c.action or "",
c.scheme or "",
c.authority or "",
c.path or "",
c.component,
c.categories,
),
)
return ordered[:limit]
def parse_intent_report(
output: str | None,
*,
limit: int = INTENT_CANDIDATE_LIMIT,
) -> IntentParseResult:
"""Parse a resolver capture into a report: candidates, count, capped or not.
An absent section yields an EMPTY report, not an answer — callers must ask
``has_activity_resolver_table`` (the manager does) before treating "no
candidates" as "this app advertises nothing".
"""
if not output:
return IntentParseResult()
section = activity_resolver_section(output)
if section is None:
return IntentParseResult()
raw: list[IntentCandidate] = []
for entry, details in _iter_filters(section):
raw.extend(_candidates_for(entry, details))
ranked = rank_candidates(raw, limit=limit)
deduped = len({candidate.key for candidate in raw})
return IntentParseResult(
candidates=tuple(ranked),
found=deduped,
truncated=deduped > len(ranked),
)
def parse_activity_resolver_table(
output: str | None,
*,
limit: int = INTENT_CANDIDATE_LIMIT,
) -> list[IntentCandidate]:
"""The candidates in a ``pm dump`` resolver capture (t16).
Deduplicated on (component, action, categories, scheme, authority, path),
deterministically ordered and capped — see ``rank_candidates``. An absent
section returns an empty list: this function reports WHAT IS THERE, and the
caller decides whether the capture was an answer at all (the manager checks
``has_activity_resolver_table`` first — a failed scan is not an answer and is
never cached as one).
"""
return list(parse_intent_report(output, limit=limit).candidates)
def intent_scan_command(package: str, *, line_limit: int = INTENT_SCAN_LINE_LIMIT) -> str:
"""The ON-DEVICE filter: only the resolver section crosses the wire (t16).
``pm dump <pkg>`` is a whole dumpsys (~2241 lines for one app on the user's
Shield), so the range is applied by the device's own ``sed`` and the answer is
additionally bounded, which is what makes the transfer proportional to the
resolver table rather than to the dump.
SAFETY: the package id is the only caller-supplied text in the command and it
is always ``shlex.quote``d; the sed script is built from the two constants
above (no interpolation of anything untrusted), so a package id containing
shell metacharacters cannot add a command — the fallback builder is quoted the
same way. A caller must still refuse an id that is not package-shaped: the
manager does (``is_launchable``).
"""
sed_range = f"/{SED_RANGE_START}/,/{SED_RANGE_END}/p"
return (
f"pm dump {shlex.quote(package)} 2>/dev/null | "
f'sed -n "{sed_range}" | '
f"head -n {int(line_limit)}"
)
def intent_scan_fallback_command(
package: str,
*,
line_limit: int = INTENT_SCAN_LINE_LIMIT,
) -> str:
"""The bounded ``grep -A`` fallback for a device without sed ranges (t16).
``pm dump <pkg> 2>/dev/null | grep -A <cap> -m 1 -F 'Activity Resolver Table:'``
— the same transfer bound as the primary command, applied with the one tool
every Android toolbox has. It is only ever a SECOND attempt: the manager runs
it when the primary answer did not contain the section at all, so a device
whose sed works never pays for it.
"""
return (
f"pm dump {shlex.quote(package)} 2>/dev/null | "
f"grep -A {int(line_limit)} -m 1 -F {shlex.quote(ACTIVITY_TABLE_HEADER)}"
)
@dataclass
class IntentCacheEntry:
"""One cached ANSWER: its state, its candidates and when it was written."""
state: str
candidates: tuple[IntentCandidate, ...]
stamp: float
truncated: bool = False
class IntentScanCache:
"""A per-package TTL cache of scans that RAN (t16).
The rule the discovery-cache defect taught us is structural here: ``put`` is
the ONLY writer, it is called only by a scan that ran, and it records which
of the two answers it holds — ``INTENT_ENTRY_CANDIDATES`` or
``INTENT_ENTRY_EMPTY``. A scan whose transport failed never reaches ``put``,
so nothing is cached and the next request retries instead of serving a
fabricated "this app advertises nothing" for the whole TTL.
A stale entry is DROPPED when it is read (never served), so callers see
either an answer inside its TTL or nothing at all.
"""
def __init__(self, *, ttl: float = INTENT_SCAN_TTL, clock=time.monotonic):
self._ttl = ttl
self._clock = clock
self._entries: dict[str, IntentCacheEntry] = {}
@property
def ttl(self) -> float:
return self._ttl
def get(self, package: str) -> IntentCacheEntry | None:
"""The fresh entry for a package, or None (dropping it when stale)."""
entry = self._entries.get(package)
if entry is None:
return None
if self._clock() - entry.stamp > self._ttl:
self._entries.pop(package, None)
return None
return entry
def put(
self,
package: str,
candidates: list[IntentCandidate] | tuple[IntentCandidate, ...],
*,
truncated: bool = False,
) -> IntentCacheEntry:
"""Record a scan that RAN: its candidates, or the empty ANSWER.
The state is derived, not passed: an empty candidate list means the
device answered with none (``INTENT_ENTRY_EMPTY``) — an answer worth
caching — while a non-empty list is ``INTENT_ENTRY_CANDIDATES``.
"""
saved = tuple(candidates)
entry = IntentCacheEntry(
state=INTENT_ENTRY_CANDIDATES if saved else INTENT_ENTRY_EMPTY,
candidates=saved,
stamp=self._clock(),
truncated=truncated,
)
self._entries[package] = entry
return entry
def state(self, package: str | None) -> str | None:
"""The cached entry's state, or None when no fresh answer is cached."""
if not package:
return None
entry = self.get(package)
return entry.state if entry is not None else None
def age(self, package: str) -> float | None:
"""How long ago the fresh entry was written, or None."""
entry = self.get(package)
return None if entry is None else self._clock() - entry.stamp
def clear(self) -> None:
"""Forget every entry (tests, and a device switch)."""
self._entries.clear()
@dataclass(frozen=True)
class IntentScanResult:
"""The answer to one scan request (t16), status included.
``candidates`` is only ever an ANSWER when ``answered`` is True. Every other
status means we do not know, which is why they all carry no candidates: an
ADB-down scan must not look like an app with no intents.
"""
package: str
status: str
candidates: tuple[IntentCandidate, ...] = ()
entry_state: str | None = None
age_seconds: float | None = None
truncated: bool = False
@property
def answered(self) -> bool:
"""Did the DEVICE answer? False means "unknown", never "no intents"."""
return self.status in _ANSWERED_STATUSES
@property
def has_candidates(self) -> bool:
"""Did the device answer WITH intents?"""
return bool(self.candidates)
def as_dict(self) -> dict:
"""A plain dict, for the options page, diagnostics and JSON."""
return {
"package": self.package,
"status": self.status,
"answered": self.answered,
"entry_state": self.entry_state,
"age_seconds": self.age_seconds,
"truncated": self.truncated,
"candidates": [candidate.as_dict() for candidate in self.candidates],
}
@@ -17,6 +17,14 @@ class MediaSessionInfo:
**device uptime-ms** timestamp (verified on Shield Android 11, R9), not a **device uptime-ms** timestamp (verified on Shield Android 11, R9), not a
wallclock value. Use :meth:`position_at_uptime` with the device's current wallclock value. Use :meth:`position_at_uptime` with the device's current
uptime to extrapolate the live position. uptime to extrapolate the live position.
``playing``/``paused``/``buffering`` are independent flags mapped from the
Android PlaybackState integer: 2 = paused, 3 = playing, 6 = buffering
(t55). 6 is the stock-Android buffering value and the mapping is INFERRED
from the ``state=PlaybackState {state=N, ...}`` line shape, not from a
captured Shield dump: no fixture in this repo contains state=6, and no
real device was available. Nothing here is vendor-specific either way —
every integer is read straight out of the dump.
""" """
title: str | None = None title: str | None = None
@@ -25,6 +33,7 @@ class MediaSessionInfo:
package: str | None = None package: str | None = None
playing: bool = False playing: bool = False
paused: bool = False paused: bool = False
buffering: bool = False
position: float | None = None position: float | None = None
speed: float | None = None speed: float | None = None
updated: int | None = None updated: int | None = None
@@ -81,7 +90,8 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
"""Parse `dumpsys media_session` output for ALL active media sessions. """Parse `dumpsys media_session` output for ALL active media sessions.
Returns a list of every active session that has metadata or a known Returns a list of every active session that has metadata or a known
playback state, sorted: playing first, then paused, then others. playback state, sorted: playing first, then buffering, then paused, then
others (t55).
""" """
if not dump: if not dump:
return [] return []
@@ -89,7 +99,8 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
sessions: list[MediaSessionInfo] = [] sessions: list[MediaSessionInfo] = []
current_pkg: str | None = None current_pkg: str | None = None
in_active = False in_active = False
current_state = 0 # 0=unknown, 2=paused, 3=playing # Android PlaybackState: 0=unknown, 2=paused, 3=playing, 6=buffering
current_state = 0
current_position: float | None = None current_position: float | None = None
current_speed: float | None = None current_speed: float | None = None
current_updated: int | None = None current_updated: int | None = None
@@ -138,6 +149,7 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
info = MediaSessionInfo() info = MediaSessionInfo()
info.package = current_pkg info.package = current_pkg
info.playing = current_state == 3 info.playing = current_state == 3
info.buffering = current_state == 6
info.paused = current_state == 2 info.paused = current_state == 2
info.position = current_position info.position = current_position
info.speed = current_speed info.speed = current_speed
@@ -158,13 +170,16 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
current_meta.package = current_pkg current_meta.package = current_pkg
sessions.append(current_meta) sessions.append(current_meta)
# Sort: playing first, then paused, then others # Sort: playing first, then buffering (mid-playback), then paused, then
# others — a buffering session outranks a paused one (t55).
def sort_key(s: MediaSessionInfo) -> int: def sort_key(s: MediaSessionInfo) -> int:
if s.playing: if s.playing:
return 0 return 0
if s.paused: if s.buffering:
return 1 return 1
if s.paused:
return 2 return 2
return 3
sessions.sort(key=sort_key) sessions.sort(key=sort_key)
return sessions return sessions
@@ -206,8 +221,9 @@ def parse_media_session(
Returns the best match from all active sessions: Returns the best match from all active sessions:
1. The session whose package matches the foreground app (if provided) 1. The session whose package matches the foreground app (if provided)
2. The session that is currently playing (state=3) 2. The session that is currently playing (state=3)
3. The session that is paused (state=2) 3. The session that is buffering (state=6)
4. Any active session with metadata 4. The session that is paused (state=2)
5. Any active session with metadata
""" """
sessions = parse_all_media_sessions(dump) sessions = parse_all_media_sessions(dump)
if not sessions: if not sessions:
@@ -218,6 +234,5 @@ def parse_media_session(
for s in sessions: for s in sessions:
if s.package == foreground_package: if s.package == foreground_package:
return s return s
# Already sorted: playing > buffering > paused > other
# Already sorted: playing > paused > other
return sessions[0] return sessions[0]
@@ -51,6 +51,8 @@ def running_apps_from_tasks(
if session: if session:
if session.playing: if session.playing:
app["state"] = "playing" app["state"] = "playing"
elif session.buffering:
app["state"] = "buffering"
elif session.paused: elif session.paused:
app["state"] = "paused" app["state"] = "paused"
else: else:
@@ -66,6 +68,8 @@ def running_apps_from_tasks(
if cached is not None: if cached is not None:
if cached.playing: if cached.playing:
app["last_known_state"] = "playing" app["last_known_state"] = "playing"
elif cached.buffering:
app["last_known_state"] = "buffering"
elif cached.paused: elif cached.paused:
app["last_known_state"] = "paused" app["last_known_state"] = "paused"
if cached.title: if cached.title:
+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))
+280
View File
@@ -0,0 +1,280 @@
"""Screen-capture image entity — an OPT-IN diagnostic surface (t66).
The user asked for "an image entity of the device screen capture? Can that be
added". This is that entity, and it is the only thing in the integration that
grabs the screen. Three boundaries are deliberate and load-bearing:
* **It never drives state.** An image entity's state IS its
``image_last_updated`` timestamp, and nothing here writes to the manager's
store, to the power ladder or to any other entity. A capture cannot make the
media player, the sensors or the power switch claim something the device did
not report. (The user's original constraint: screen capture must never drive
state.)
* **It is opt-in, default OFF** (``CONF_SCREEN_CAPTURE_ENABLED``). With the
option absent ``async_setup_entry`` adds no entity at all.
* **It never polls.** ``ImageEntity`` ships ``_attr_should_poll = False`` ("no
need to poll image entities") and this module keeps it. Nothing runs on a
timer; a capture happens only when something asks for the bytes (the image
proxy serving ``entity_picture``) or when the user calls
``homeassistant.update_entity``.
BINARY-SAFE CAPTURE — base64 over the shell, and why not a device file
=====================================================================
``AdbDevice.shell()`` returns ``str``: it decodes the shell channel as text, so
raw PNG bytes cannot travel through it — ``\\x89PNG`` is not UTF-8 and the
undecodable bytes would be mangled on the way out. There are two binary-safe
routes and this module takes the SECOND:
1. ``screencap -p /sdcard/<name>.png`` then ``AdbDevice.pull()`` — the library's
real binary path (the ADB sync protocol). It needs the raw ``AdbDevice``
object, which the ADB client owns as a private attribute, and it writes to
the device's shared storage: something must delete that file afterwards, and
a crash between write and delete leaves a full-resolution screenshot behind
on the user's device.
2. ``screencap -p | base64`` then ``base64.b64decode`` locally. Base64 output is
pure ASCII, so the shell's text decoding cannot corrupt it, and there is no
device-side file at all — nothing to clean up, nothing to leak.
Route 2 costs ~33% more bytes on the socket than route 1, and one claim is
INFERRED rather than measured: no Shield was available here, so how the
persistent ADB socket handles a few hundred kilobytes of base64 in one
``shell()`` call is untested on hardware (the README says so). It is still the
right trade: it uses the PUBLIC seam the watchdog's socket already offers
(``manager.adb_poller.shell``), leaves no artifact on the device, and its
correctness on the wire is provable here with a real PNG fixture.
One capture costs exactly ONE shell, whatever the caller does: concurrent
requests share a lock, and a successful capture is reused for
``CAPTURE_REUSE_SECONDS`` so a dashboard repainting cannot hammer the device.
"""
from __future__ import annotations
import asyncio
import base64
import binascii
import logging
import time
from homeassistant.components.image import ImageEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import CONF_SCREEN_CAPTURE_ENABLED, DOMAIN
_LOGGER = logging.getLogger(__name__)
#: ``screencap -p`` writes PNG to stdout; ``base64`` makes it ASCII, which is the
#: whole trick — see the module docstring.
SCREEN_CAPTURE_COMMAND = "screencap -p | base64"
#: The PNG file signature. Nothing but real PNG bytes ever leaves this module.
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
#: A successful capture is reused for this long. Several image fetches in a row
#: (a dashboard reload, two clients, a stream preview) must not become one screen
#: grab each — "cannot hammer the device" is a property of the code, not a hope.
CAPTURE_REUSE_SECONDS = 5.0
#: The reason the entity is (un)available, published as ``capture_status``.
STATUS_OK = "ok"
STATUS_ADB_DISABLED = "adb_disabled"
STATUS_ADB_DISCONNECTED = "adb_disconnected"
STATUS_CAPTURE_FAILED = "capture_failed"
#: The attribute HA reads beside the state — declared unrecorded (G12).
ATTR_CAPTURE_STATUS = "capture_status"
def decode_screen_capture(output: str | None) -> bytes | None:
"""Decode one ``screencap -p | base64`` answer into PNG bytes, or None.
Pure on purpose, so the one part that can corrupt an image is testable
without a device: the answer is base64 with ``base64``'s line wrapping (the
whitespace is not part of the payload), the decoded bytes MUST carry the PNG
signature, and every other outcome — empty output, a shell that never
answered, a device that printed an error line instead — is a failure that
returns None rather than a corrupt or zero-byte image.
"""
if not output:
return None
try:
data = base64.b64decode("".join(output.split()), validate=True)
except (binascii.Error, ValueError):
return None
if not data.startswith(PNG_SIGNATURE):
return None
# The signature alone is not a PNG: the spec puts IHDR as the FIRST chunk,
# immediately after it (length 13, type "IHDR"). A shell that returned the
# signature plus junk — or a device that answered with a header in another
# format — fails here. What CANNOT be detected locally is a truncation AFTER
# the header: those bytes are still a well-formed PNG prefix, and the
# consumer (HA's image proxy, then the browser) is what rejects them.
if data[12:16] != b"IHDR":
return None
return data
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Add the screen-capture entity — only when the user opted in (t66)."""
if not entry.options.get(CONF_SCREEN_CAPTURE_ENABLED, False):
return # default OFF: no entity, no capture, no cost
manager = getattr(entry, "runtime_data", None)
if manager is None:
_LOGGER.debug("Screen capture skipped: no runtime manager on the entry")
return
async_add_entities([NvidiaShieldScreenImage(hass, entry)])
class NvidiaShieldScreenImage(ImageEntity):
"""The Shield's current screen as a PNG — manual, diagnostic, never a source.
A capture is fetched on demand and nothing else: the entity publishes no
command, writes no state source and is not polled. Its state is the
timestamp of the last successful capture, and its ``capture_status``
attribute names why it cannot serve an image when it cannot.
"""
_attr_has_entity_name = True
_attr_translation_key = "screen_capture"
_attr_content_type = "image/png"
# HA's own default for image entities, kept explicitly: this entity must
# never turn into a timer that grabs the screen every scan interval.
_attr_should_poll = False
# G12: a poll-adjacent value must not bloat the recorder database, and the
# image bytes are never part of the state at all.
_unrecorded_attributes = frozenset({ATTR_CAPTURE_STATUS})
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Initialise the entity over the entry's runtime manager.
The two-argument shape every platform in this integration uses: the
manager travels on ``entry.runtime_data`` (t40's wiring), so the real-HA
entity matrix can build this class exactly like the others.
"""
super().__init__(hass)
# ImageEntity.__init__ builds HA's shared httpx client but does NOT set
# ``hass`` (the platform does that in production). The rest of this
# integration's entities assign it themselves, and without it HA's own
# state write raises "Attribute hass is None" — measured in the real-HA
# matrix, which builds entities the same way the tests do.
self.hass = hass
self._entry = entry
self._png: bytes | None = None
self._captured_at: float = 0.0
# One shell at a time, however many callers ask for the image.
self._lock = asyncio.Lock()
poller = self._adb_poller()
self._status = (
STATUS_ADB_DISABLED
if poller is None
else STATUS_OK
if poller.connected
else STATUS_ADB_DISCONNECTED
)
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_screen_capture"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name=entry.title,
manufacturer="NVIDIA",
model="Shield TV",
)
# -- availability ------------------------------------------------------
def _adb_poller(self):
"""The ADB client this entry owns, or None when ADB is off/not built."""
manager = getattr(self._entry, "runtime_data", None)
if manager is None:
return None
return getattr(manager, "adb_poller", None)
@property
def available(self) -> bool:
"""True only while a capture can actually be served.
Unavailable is the honest answer for "ADB is off", "ADB is down" and "the
last capture failed"; the alternative — being available and handing HA an
empty image — is exactly the blank screenshot this must never produce.
"""
return self._status == STATUS_OK
@property
def capture_status(self) -> str:
"""Why the entity is (un)available — the same value as the attribute."""
return self._status
@property
def extra_state_attributes(self) -> dict:
"""The reason behind the availability, as a PROPERTY (HA reads attrs)."""
return {ATTR_CAPTURE_STATUS: self._status}
# -- the capture path --------------------------------------------------
async def async_image(self) -> bytes | None:
"""Return the PNG bytes, capturing on demand (the image proxy seam)."""
return await self._async_capture()
async def async_update(self) -> None:
"""Explicit refresh (``homeassistant.update_entity``) — skip the reuse.
This is also how a failed capture is retried: a failure makes the entity
unavailable on purpose, and an explicit refresh is the user's way back
without waiting for the next dashboard fetch.
"""
await self._async_capture(force=True)
async def _async_capture(self, *, force: bool = False) -> bytes | None:
"""One screen grab, shared by everyone who asks within the reuse window."""
async with self._lock:
if (
not force
and self._png is not None
and time.monotonic() - self._captured_at < CAPTURE_REUSE_SECONDS
):
return self._png
poller = self._adb_poller()
if poller is None:
return self._fail(STATUS_ADB_DISABLED)
if not poller.connected:
return self._fail(STATUS_ADB_DISCONNECTED)
try:
output = await poller.shell(SCREEN_CAPTURE_COMMAND)
except Exception as err: # the transport is remote; it may raise anything
# ``AdbClientBase.shell`` already turns its own failures into
# None, so this is defence in depth — a raised error must still
# end as "unavailable", never as a blank image in HA.
_LOGGER.warning("Screen capture failed on %s: %s", self._entry.title, err)
return self._fail(STATUS_CAPTURE_FAILED)
data = decode_screen_capture(output)
if data is None:
# Never a blank or zero-byte image: no answer, a truncated one,
# an error line or a non-PNG all land here.
_LOGGER.warning(
"Screen capture from %s did not return a PNG (%d bytes of "
"shell output)",
self._entry.title,
len(output or ""),
)
return self._fail(STATUS_CAPTURE_FAILED)
self._png = data
self._captured_at = time.monotonic()
self._status = STATUS_OK
self._attr_image_last_updated = dt_util.utcnow()
self.async_write_ha_state()
return data
def _fail(self, status: str) -> None:
"""Record why no image can be served and stay unavailable."""
self._status = status
return None
@@ -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.12.2",
"zeroconf": [ "zeroconf": [
"_androidtvremote2._tcp.local." "_androidtvremote2._tcp.local."
], ],
File diff suppressed because it is too large. Load diff
+364 -24
View File
@@ -9,22 +9,58 @@ remote-protocol transport (androidtvremote2) owned by ShieldConnectionManager:
long presses are expressed as the START_LONG/END_LONG pair instead; long presses are expressed as the START_LONG/END_LONG pair instead;
- ``num_repeats`` / ``delay_secs`` are honored; long-press duration uses the - ``num_repeats`` / ``delay_secs`` are honored; long-press duration uses the
``hold_secs`` parameter; ``hold_secs`` parameter;
- ``activity`` = the protocol current-app push (package; name resolution is - ``RemoteEntityFeature.ACTIVITY`` with an ``activity_list`` built from OUR app
deferred app-map work); directory — the library exposes NO app-list API (measured), which is exactly why
- turn on/off send the POWER key and honor the is_on push (no superfluous core's list is user-maintained too — so the app/activity picker matches core;
toggling when the state already matches). - ``current_activity`` resolves the foreground push (protocol first, ADB parse as
the fallback) through the one ``resolve_app_name`` chain, and stays None while
nothing is known: no fabricated activity, ever;
- ``turn_on(activity=...)`` wakes when the device says it is off and then launches
through the same path ``select_source`` uses; an activity name the directory does
not carry is a ``ServiceValidationError``, never a silent no-op;
- the transport's REAL state is published in ``extra_state_attributes`` so a
never-connected transport cannot look like a paired one (no ``is_paired`` exists:
a session or a push is the only proof);
- a configured command script still owns turn_on/turn_off and never touches the
protocol, so a press that worked through it says NOTHING about pairing — the
attributes name which route each power command takes.
""" """
import asyncio import asyncio
import os
from homeassistant.components.remote import RemoteEntity from homeassistant.components.remote import (
ATTR_ACTIVITY,
RemoteEntity,
RemoteEntityFeature,
)
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .connection import ShieldConnectionManager from .connection import ShieldConnectionManager
from .const import DOMAIN, PROTOCOL_REMOTE_KEYS from .const import (
CONF_APP_NAME_MAP,
DOMAIN,
PROTOCOL_REMOTE_KEYS,
SCRIPT_VAR_COMMAND,
SCRIPT_VAR_DELAY_SECS,
SCRIPT_VAR_HOLD_SECS,
SCRIPT_VAR_NUM_REPEATS,
command_scripts,
remote_command_script,
)
from .helpers import protocol_cert_paths
from .media_player import (
activity_names,
app_directory,
async_call_script,
async_run_command_script,
entity_available,
resolve_app_name,
)
_START_PREFIX = "START_LONG:" _START_PREFIX = "START_LONG:"
_END_PREFIX = "END_LONG:" _END_PREFIX = "END_LONG:"
@@ -55,6 +91,19 @@ def _key_code_name(command: str) -> str:
return upper # unknown names: the library reports/validates them return upper # unknown names: the library reports/validates them
def _looks_launchable(value: str) -> bool:
"""True for a package id or a detail link — forms the launch path accepts (t15).
The rule is the repo's own definition of a package (``_valid_app_row``: dotted,
no spaces) plus URL-shaped deep links, which is exactly what
``send_launch_app_command`` handles. It is deliberately NOT a guess that an id
exists: the device answers that. It exists so "Netflx" (a typo of a listed name)
is refused while an app the directory does not carry can still be launched.
"""
if not value or " " in value:
return False
return "." in value or "://" in value
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: ConfigEntry, entry: ConfigEntry,
@@ -65,11 +114,21 @@ async def async_setup_entry(
class NvidiaShieldRemoteEntity(RemoteEntity): class NvidiaShieldRemoteEntity(RemoteEntity):
"""Remote entity over the manager's androidtvremote2 transport.""" """Remote entity over the manager's androidtvremote2 transport.
t15: this is the entity the app/activity picker hangs off. HA renders the picker
only when ``supported_features`` carries ``RemoteEntityFeature.ACTIVITY`` — the
user's "the UI doesn't actually show any buttons like the android tv remote
does" — and the list itself has to come from OUR directory, because
``AndroidTVRemote`` exposes no app-list API at all (measured on the installed
library; core androidtv_remote keeps a user-maintained map for the same reason).
"""
_attr_has_entity_name = True _attr_has_entity_name = True
_attr_name = None _attr_name = None
_attr_should_poll = False _attr_should_poll = False
#: The feature flag that makes HA offer the activity/app picker at all.
_attr_supported_features = RemoteEntityFeature.ACTIVITY
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
self.hass = hass self.hass = hass
@@ -78,10 +137,58 @@ class NvidiaShieldRemoteEntity(RemoteEntity):
entry, "runtime_data", None entry, "runtime_data", None
) )
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_remote" self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_remote"
# t15: the activity list is available from the FIRST render (a real-HA state
# write needs no entity platform), and is refreshed with discovered apps once
# ADB can answer (``async_added_to_hass``).
self._attr_activity_list = activity_names(self._entry.options)
self._attr_current_activity = self._resolve_activity_name()
#: Which protocol pushes have EVER arrived — the only pairing evidence the
#: library can offer (it has no is_paired). Sticky on purpose: the store
#: expires every field after STATE_STORE_TTL, so "has it ever arrived?"
#: cannot be read from the store.
self._protocol_pushes_seen: set[str] = set()
self._note_protocol_pushes()
#: "present"/"missing"/"unknown" — the shared setup certificate, which is
#: why enabling the transport on a paired entry asks for no new PIN.
self._protocol_certificate = "unknown"
self._activity_refresh_task = None
def _manager_of(self) -> ShieldConnectionManager | None: def _manager_of(self) -> ShieldConnectionManager | None:
return getattr(self._entry, "runtime_data", None) return getattr(self._entry, "runtime_data", None)
def _resolve_activity_name(self) -> str | None:
"""The foreground app as a friendly name (t15), or None when nothing is known.
The same chain as the media player's ``app_name`` and the Current App sensor
(``resolve_app_name``): the user's map, then the cached device label, then the
curated table, then the raw id — so an activity and a source can never
disagree about what is on screen. The foreground identity prefers the protocol
push and falls back to the ADB parse, which keeps this entity useful on an
ADB-only setup (its protocol surface alone degrades).
"""
manager = self._manager_of()
if not isinstance(manager, ShieldConnectionManager):
return None
return resolve_app_name(
manager.foreground_app,
self._entry.options.get(CONF_APP_NAME_MAP) or {},
manager.app_label,
)
def _note_protocol_pushes(self) -> None:
"""Remember which device pushes have arrived (t15) — sticky, see __init__."""
manager = self._manager_of()
if not isinstance(manager, ShieldConnectionManager):
return
if manager.current_app is not None:
self._protocol_pushes_seen.add("current_app")
if manager.is_on is not None:
self._protocol_pushes_seen.add("is_on")
if manager.is_available is not None:
self._protocol_pushes_seen.add("is_available")
if manager.volume_info is not None:
self._protocol_pushes_seen.add("volume")
@property @property
def device_info(self) -> DeviceInfo: def device_info(self) -> DeviceInfo:
return DeviceInfo( return DeviceInfo(
@@ -93,39 +200,177 @@ 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:
"""Current foreground app reported by the protocol push.""" """The raw foreground package (protocol push; ADB parse as the fallback).
Kept alongside HA's ``current_activity``: this is the id (what
``turn_on(activity=...)`` accepts and what the Current App sensor stamps),
while ``current_activity`` is the friendly name HA displays.
"""
manager = self._manager_of() manager = self._manager_of()
if isinstance(manager, ShieldConnectionManager): if isinstance(manager, ShieldConnectionManager):
return manager.current_app return manager.foreground_app
return None return None
@property
def extra_state_attributes(self) -> dict:
"""The transport's real state — the pairing answer, readable from HA (t15).
There is no "is it paired?" API to call (the library exposes none either,
measured), so everything here is a fact that can be PROVEN:
* ``protocol_state`` — ``off`` / ``never_connected`` / ``connected`` /
``disconnected`` / ``auth_rejected``. ``never_connected`` is the state
that used to be permanent (``keep_reconnecting()`` alone never connects),
and it must never look like ``connected``.
* ``protocol_connected`` / ``protocol_ever_authenticated`` — a session or a
device push is the only proof of an authenticated (paired) client.
* ``protocol_current_app_seen`` / ``protocol_is_on_seen`` /
``protocol_volume_seen`` — whether each device push has EVER arrived.
* ``protocol_last_error`` / ``protocol_failures`` — a send that could not
run is recorded here and logged, never swallowed into a debug line. The
ERROR is the failure the CURRENT state has not recovered from (t20/O1):
it is cleared as soon as a session is proven again — the next connect or
the transport's own availability push — so it can never read as a
problem beside ``protocol_connected: true``, and a failure that happened
AFTER the session came up is kept because that one is still current.
``protocol_failures`` keeps counting, so a recovery stays visible as
history instead of as a stale message.
* ``protocol_certificate`` — the setup pairing's shared ``client.pem`` is
still on disk, which is exactly why enabling the transport asks for no
new PIN (the other Android TV integration pairs its own client).
* ``power_on_route`` / ``power_off_route`` — ``script:<id>`` when a command
script owns that command. That path never touches the protocol, so a
success through it says NOTHING about pairing.
"""
return {
"activity": self.activity,
# t15: which tier produced it — a protocol push must never win SILENTLY
# over the ADB parse the user's setup is built on (see the manager's
# ``foreground_source``).
"activity_source": self._protocol_read("foreground_source"),
"power_on_route": self._power_route("turn_on"),
"power_off_route": self._power_route("turn_off"),
"protocol_active": self._protocol_active(),
"protocol_state": self._protocol_state(),
"protocol_connected": self._protocol_flag("protocol_connected"),
"protocol_ever_authenticated": self._protocol_flag(
"protocol_ever_authenticated"
),
"protocol_last_error": self._protocol_read("protocol_last_error"),
"protocol_failures": self._protocol_read("protocol_failures"),
"protocol_certificate": self._protocol_certificate,
"protocol_current_app_seen": "current_app" in self._protocol_pushes_seen,
"protocol_is_on_seen": "is_on" in self._protocol_pushes_seen,
"protocol_volume_seen": "volume" in self._protocol_pushes_seen,
}
def _protocol_read(self, name: str, default=None):
"""Read one transport fact off the manager, or the default (stub managers)."""
manager = self._manager_of()
if not isinstance(manager, ShieldConnectionManager):
return default
return getattr(manager, name, default)
def _protocol_active(self) -> bool:
return bool(self._protocol_read("protocol_active", False))
def _protocol_state(self) -> str:
"""The transport state, with ``off`` for a manager that carries no client."""
if not self._protocol_active():
return "off"
return self._protocol_read("protocol_state", "never_connected")
def _protocol_flag(self, name: str) -> bool:
return bool(self._protocol_read(name, False))
def _power_route(self, command: str) -> str:
"""Which path ``turn_on``/``turn_off`` takes: a script, or the protocol (t15).
A configured command script owns the sequence and never touches the
protocol — so "the power press worked" through it is NOT evidence that the
transport is paired, or even connected. That is the conclusion the user drew
and then doubted, made readable instead of inferred.
"""
script = (command_scripts(self._entry.options) or {}).get(command)
return f"script:{script}" if script else "protocol"
@callback @callback
def _refresh_state(self) -> None: def _refresh_state(self) -> None:
"""Manager store changed — re-evaluate state (activity/available).""" """Manager store changed — re-resolve activity, availability, transport."""
self._manager = self._manager_of() self._manager = self._manager_of()
self._note_protocol_pushes()
self._attr_current_activity = self._resolve_activity_name()
self.async_write_ha_state() self.async_write_ha_state()
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
manager = self._manager_of() manager = self._manager_of()
if isinstance(manager, ShieldConnectionManager): if isinstance(manager, ShieldConnectionManager):
self._manager_unregistered = manager.register_listener( self._manager_unregistered = manager.register_listener(self._refresh_state)
self._refresh_state self._note_protocol_pushes()
await self._async_note_certificate()
self._schedule_activity_refresh()
async def _async_note_certificate(self) -> None:
"""Record whether the shared pairing certificate exists (t15).
This is the "no new PIN is expected" evidence, and it is a design fact, not a
guess: ``protocol_cert_paths`` is shared between the config flow's pairing step
and this runtime, so an entry paired at setup reuses that ``client.pem`` when
the transport is (re-)enabled. The OTHER Android TV integration pairs its own
client and shares nothing with us.
"""
add_executor_job = getattr(self.hass, "async_add_executor_job", None)
if add_executor_job is None:
return # a hass stand-in: leave it "unknown" rather than guess
try:
certfile, keyfile = await add_executor_job(protocol_cert_paths, self.hass)
except Exception: # pragma: no cover - a filesystem we cannot read
return
self._protocol_certificate = (
"present" if os.path.exists(certfile) and os.path.exists(keyfile) else "missing"
) )
def _schedule_activity_refresh(self) -> None:
"""Add the apps ADB discovery actually found to the activity list (t15).
One bounded sweep, on demand — the t57 path the options editor uses — never a
per-state-write shell. The list is already complete for a configured
directory, and a sweep that cannot run changes nothing (no fabrication).
"""
manager = self._manager_of()
create_task = getattr(self.hass, "async_create_task", None)
if create_task is None or not isinstance(manager, ShieldConnectionManager):
return
if not callable(getattr(manager, "async_refresh_installed_apps", None)):
return # a manager stand-in: the directory alone is the honest list
self._activity_refresh_task = create_task(
self._async_refresh_activity_list(manager),
"nvidia_shield_remote_activities",
)
async def _async_refresh_activity_list(self, manager) -> None:
"""Fold discovered apps into the activity list (t15) — one write if it grew."""
try:
rows = await manager.async_refresh_installed_apps()
except Exception: # pragma: no cover - discovery never breaks the entity
return
names = activity_names(self._entry.options, rows)
if names != self._attr_activity_list:
self._attr_activity_list = names
self.async_write_ha_state()
async def async_will_remove_from_hass(self) -> None: async def async_will_remove_from_hass(self) -> None:
unregistered = getattr(self, "_manager_unregistered", None) unregistered = getattr(self, "_manager_unregistered", None)
if unregistered is not None: if unregistered is not None:
@@ -133,7 +378,33 @@ 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, and launch a requested activity (t15).
With NO activity — or an empty one — this behaves exactly as before: the
configured script owns the wake sequence when there is one, otherwise POWER
goes out only when the device says it is off (unknown counts as "maybe on",
so nothing is toggled blindly).
With ``activity``, the same wake happens first (a script that owns turn_on
still owns it — it never touches the protocol), and then the app is launched
over the protocol through the same path ``select_source`` uses. A name the
activity list does not carry, and that is not a package/detail link either,
raises ``ServiceValidationError``: an activity request that cannot launch
must never look like it worked.
"""
activity = str(kwargs.get(ATTR_ACTIVITY) or "").strip()
await self._async_wake()
if activity:
await self._async_launch_activity(activity)
async def _async_wake(self) -> None:
"""Turn the device on: the configured script first, else the POWER key (t44).
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
@@ -142,8 +413,55 @@ class NvidiaShieldRemoteEntity(RemoteEntity):
return return
await manager.send_protocol_key(_POWER_KEY, "SHORT") await manager.send_protocol_key(_POWER_KEY, "SHORT")
async def _async_launch_activity(self, activity: str) -> None:
"""Launch an activity through the shared launch path (t15).
The target comes from the same directory the activity list is built from, so
anything the picker offers resolves. A package id or a deep link passes
through: that is how an app the directory does not carry can still be
launched, and what core does with an activity it does not know. Anything else
is a request nothing can launch and raises instead of doing nothing.
"""
target = self._activity_target(activity)
manager = self._manager_of()
if not isinstance(manager, ShieldConnectionManager):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="protocol_unavailable",
translation_placeholders={"activity": activity},
)
if not await manager.launch_app(target):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="activity_launch_failed",
translation_placeholders={"activity": activity},
)
def _activity_target(self, activity: str) -> str:
"""Activity name (or id) → what the launch path takes (t15).
A listed NAME wins, compared case-insensitively — the same rule the
``source_list``/``select_source`` pair uses, so the picker can never offer a
name this does not resolve. A ``CONF_APP_NAME_MAP``-less entry still resolves
the curated names, because both read the one ``app_directory``.
"""
directory = app_directory(self._entry.options)
wanted = activity.casefold()
for package, name in directory.items():
if name.casefold() == wanted:
return package
if activity in directory or _looks_launchable(activity):
return activity
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="unknown_activity",
translation_placeholders={"activity": activity},
)
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 +476,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
+172 -51
View File
@@ -12,10 +12,12 @@ 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_ADB_ENABLED,
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__)
@@ -27,6 +29,10 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up the Nvidia Shield sensor entities.""" """Set up the Nvidia Shield sensor entities."""
entities = [NvidiaShieldCurrentAppSensor(hass, entry)] entities = [NvidiaShieldCurrentAppSensor(hass, entry)]
if entry.options.get(CONF_ADB_ENABLED, True):
# discovery is an ADB feature; with the transport off there is nothing
# to enumerate and the entity would only ever report "unavailable"
entities.append(NvidiaShieldAppsSensor(hass, entry))
if entry.options.get(CONF_JELLYFIN_ENTITY): if entry.options.get(CONF_JELLYFIN_ENTITY):
entities.append(NvidiaShieldNowPlayingSensor(hass, entry)) entities.append(NvidiaShieldNowPlayingSensor(hass, entry))
@@ -40,7 +46,9 @@ class NvidiaShieldSensorBase(SensorEntity):
_attr_has_entity_name = True _attr_has_entity_name = True
_attr_should_poll = False _attr_should_poll = False
# G12 (t30): poll-driven attributes never belong in the database # G12 (t30): poll-driven attributes never belong in the database
_unrecorded_attributes = frozenset({"app_id", "app_name", "playback_state"}) _unrecorded_attributes = frozenset(
{"app_id", "app_name", "playback_state", "installed_apps"}
)
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
self.hass = hass self.hass = hass
@@ -65,9 +73,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 +83,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,34 +90,27 @@ 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 package of the app that is on screen (t41, t55).
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 (both via ``manager.foreground_app``) → the most
recent visible ADB recent task. The recents tier is a last resort for
a moment when neither live signal has reported yet; it keeps the
sensor from going None while ADB is still warming up.
"""
manager = self._manager() manager = self._manager()
if manager is not None: if manager is None:
return None
foreground = manager.foreground_app foreground = manager.foreground_app
if foreground: if foreground:
return foreground return foreground
@@ -119,20 +119,140 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
return running[0].get("package") return running[0].get("package")
return None return None
@property
def native_value(self) -> str | None:
"""The resolved friendly name (t40), not the raw package id.
Exactly the ``media_player.app_name`` chain via the shared
``resolve_app_name``: apps map → cached device label → the built-in app
names (t66) → the package text itself. A bare or unknown package shows its
package text rather than None, like the media player's app_name, and that
text is the id ONCE — never a ``pkg=pkg`` pair.
This is why ``_package`` must be the CURRENT app and the label fetch
must be driven off it (t55): the device-manifest label is resolved on
demand for exactly the package passed here, so a live run showing a
package id means the label tier never answered — see
``ShieldConnectionManager.app_label`` for the caching rule that makes
a miss retry instead of being stamped as an eternal "no label".
"""
manager = self._manager()
return resolve_app_name(
self._package(),
dict(self._entry.options.get(CONF_APP_NAME_MAP) or {}),
manager.app_label if manager is not None else None,
)
@property @property
def extra_state_attributes(self) -> dict: def extra_state_attributes(self) -> dict:
state = self.hass.states.get(self._androidtv_entity) """The raw package stays visible here (t40) next to the shown name."""
if state: package = self._package()
return { attrs: dict = {}
"app_id": state.attributes.get("app_id"), if package:
"app_name": state.attributes.get("app_name"), attrs["app_id"] = package
} name = self.native_value
if name:
attrs["app_name"] = name
return attrs
class NvidiaShieldAppsSensor(NvidiaShieldSensorBase):
"""The apps installed on the Shield (t57) — a dashboard-facing surface.
The state is the app COUNT and the ``installed_apps`` attribute is the
table: ``[{package, name, icon}, ...]``, exactly what the Configure page's
Apps sub-screen edits. It is the answer to "it doesn't find all the apps":
the list comes from the device (``pm list packages -3`` plus the active
launcher), labelled through the cached ``pm dump`` path.
Only ADB-enabled entries get one. Discovery is a handful of shells, so it
runs once when the entity is added and then only when the table has gone
stale: ``async_update`` goes through the manager's TTL cache, so a poll on a
fresh table issues NO shell. A sweep that could not run is NOT an answer
(t62 F1): it is recorded as ``unknown`` — or shown as ``not_connected``
while the transport itself is down — and retried on the next update instead
of being frozen as a stamped-empty table for APP_LIST_TTL, while a
sweep that ran and found nothing IS the answer ``ok`` with a count of 0. On
top of that the user can ask for a refresh with the standard
``homeassistant.update_entity`` action, and the Configure page runs the same
sweep from its discovery row. With ADB off, or unreachable, the attributes
say why and the state is 0 rather than raising.
"""
_attr_translation_key = "installed_apps"
_attr_icon = "mdi:apps"
# Polling is what makes the standard `homeassistant.update_entity` action
# work for it — the user-facing "refresh the app list" trigger. The poll is
# free while the table is fresh: the manager returns the cache and issues no
# shell until APP_LIST_TTL expires. A sweep that COULD NOT run leaves no
# fresh table (t62 F1), so a later poll retries it — that retry is the
# recovery the old stamped-empty answer blocked for up to six hours.
_attr_should_poll = True
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
super().__init__(hass, entry)
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_installed_apps"
def _manager(self) -> ShieldConnectionManager | None:
manager = getattr(self._entry, "runtime_data", None)
return manager if isinstance(manager, ShieldConnectionManager) else None
async def async_added_to_hass(self) -> None:
manager = self._manager() manager = self._manager()
if manager is not None: if manager is not None:
running = manager.running_apps self._unsub_listeners.append(manager.register_listener(self._handle_store_update))
if running: # one discovery per entity lifetime; a load is already a wake-up moment,
return {"app_id": running[0].get("package")} # and nothing here runs on a state poll
return {} self.hass.async_create_task(self.async_update())
@callback
def _handle_store_update(self) -> None:
if self.hass is not None:
self.async_write_ha_state()
async def async_update(self) -> None:
"""Refresh the table when there is no fresh answer (t57, t62 F1).
HA's own poll (``_attr_should_poll``) and an explicit
``homeassistant.update_entity`` both land here; the manager's TTL makes
it a no-op while the table is fresh, and a retry while it is not —
including after a sweep that could not run.
"""
manager = self._manager()
if manager is None:
return
await manager.async_refresh_installed_apps()
@property
def native_value(self) -> int:
manager = self._manager()
return len(manager.installed_apps()) if manager is not None else 0
@property
def extra_state_attributes(self) -> dict:
"""The table itself, plus why it may be empty (never a crash).
``discovery`` is the honest reason (t62 F1): ``ok`` means the table IS
the answer, including a genuinely empty one, while ``unknown`` means
the last sweep could not run — so an empty list says nothing about the
device and the next update retries.
"""
manager = self._manager()
if manager is None:
return {"installed_apps": [], "discovery": "unavailable"}
apps = manager.installed_apps()
attrs = {"installed_apps": apps}
if apps:
attrs["discovery"] = "ok"
elif not self._entry.options.get(CONF_ADB_ENABLED, True):
attrs["discovery"] = "adb_disabled"
elif not manager.connected:
attrs["discovery"] = "not_connected"
else:
# "ok" here is a real empty answer, "unknown" a sweep that could
# not run, "pending" one that has not completed yet.
attrs["discovery"] = manager.installed_apps_status
return attrs
class NvidiaShieldNowPlayingSensor(NvidiaShieldSensorBase): class NvidiaShieldNowPlayingSensor(NvidiaShieldSensorBase):
@@ -180,23 +300,24 @@ class NvidiaShieldNowPlayingSensor(NvidiaShieldSensorBase):
return {"playback_state": "idle"} return {"playback_state": "idle"}
attrs = state.attributes attrs = state.attributes
result = { # t65: the same "no None rows" rule the media player's flattened surface
"playback_state": state.state, # follows — an attribute that reads `title: null` in the template editor
"title": attrs.get("media_title"), # is noise, so a field the Jellyfin entity did not answer is OMITTED
"media_type": attrs.get("media_content_type"), # rather than published. Zero stays: 0 s of position is a real reading.
} result = {"playback_state": state.state}
for key, source in (
if attrs.get("media_series_title"): ("title", "media_title"),
result["series"] = attrs["media_series_title"] ("media_type", "media_content_type"),
if attrs.get("media_season") is not None: ("series", "media_series_title"),
result["season"] = attrs["media_season"] ("season", "media_season"),
if attrs.get("media_episode") is not None: ("episode", "media_episode"),
result["episode"] = attrs["media_episode"] ("duration", "media_duration"),
if attrs.get("media_duration") is not None: ("position", "media_position"),
result["duration"] = attrs["media_duration"] ("thumbnail", "entity_picture"),
if attrs.get("media_position") is not None: ):
result["position"] = attrs["media_position"] value = attrs.get(source)
if attrs.get("entity_picture"): if value is None or value == "":
result["thumbnail"] = attrs["entity_picture"] continue
result[key] = value
return result return result
+418 -39
View File
@@ -3,84 +3,137 @@
"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 — 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, soundbar or speaker switch this Shield plays through. Leave blank if there is none.",
"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 — 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 — 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." }
},
"menu": {
"title": "Finish setup",
"description": "Pair the transports and check anything else before the entry is created. {pairing}.",
"data": {
"host": "The Shield's IP address",
"adb_port": "ADB port",
"adb_enabled": "Read state over ADB",
"protocol_enabled": "Add the Nvidia Remote Protocol"
},
"data_description": {
"host": "The Shield's IP address. Pre-filled when the Shield was discovered — change it only if you need a different address.",
"adb_port": "The ADB port. The standard value is 5555 — change it only if ADB uses a custom port on your network.",
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
"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."
},
"menu_options": {
"receiver": "Receiver",
"connection": "Connection",
"adb_pair": "Set up ADB",
"protocol_pair": "Pair the remote protocol",
"finish": "Create the entry"
}
},
"receiver": {
"title": "Receiver",
"description": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch. Playback turns a switch receiver on when it is off.",
"data": {
"receiver_entity": "Receiver",
"receiver_input": "Receiver input"
},
"data_description": {
"receiver_entity": "The receiver, soundbar or speaker switch this Shield plays through. Leave blank if there is none.",
"receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure."
}
},
"connection": {
"title": "Connection",
"description": "The Shield's address, the ADB port and which transports this entry uses.",
"data": {
"host": "The Shield's IP address",
"adb_port": "ADB port",
"adb_enabled": "Read state over ADB",
"protocol_enabled": "Add the Nvidia Remote Protocol"
},
"data_description": {
"host": "The Shield's IP address. Pre-filled when the Shield was discovered — change it only if you need a different address.",
"adb_port": "The ADB port. The standard value is 5555 — change it only if ADB uses a custom port on your network.",
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
"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."
} }
}, },
"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 — 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 — 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."
} }
}, },
"protocol_pair": { "protocol_pair": {
"title": "Pair the remote protocol", "title": "Pair the remote protocol",
"description": "While this step is open the Shield shows a pairing code. Type it below and select Submit. If no code appears on the Shield, check the IP address below and select Submit again \u2014 pairing restarts and the Shield shows a new code. To set up without the remote protocol, turn on 'Continue without protocol pairing'.", "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 — 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 — 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 — it stays off until you pair it. Pair later from this integration's entry menu: choose Reconfigure."
} }
}, },
"reauth_confirm": { "reauth_confirm": {
"title": "Re-pair Nvidia Shield", "title": "Re-pair Nvidia Shield",
"description": "The Shield no longer trusts the saved pairing certificate, usually after a factory reset. Select Submit to pair again \u2014 the Shield then shows a code." "description": "The Shield no longer trusts the saved pairing certificate, usually after a factory reset. Select Submit to pair again — the Shield then shows a code."
} }
}, },
"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 — or turn on 'Continue without protocol pairing' to finish setup without it.",
"invalid_app_map": "Each line must be package=name with a non-empty package (no spaces) and a non-empty name \u2014 for example ar.tvplayer.tv=TiviMate.", "no_state_source": "No state source selected. Choose at least one: 'Add the Nvidia Remote Protocol' or 'Read state over ADB'.",
"invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds." "invalid_app_map": "The apps text was not saved — nothing was changed. {invalid_lines} Fix those lines and select Submit again, or clear the field to go back to the built-in list.",
"invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds.",
"invalid_app_row": "A row needs a package id with no spaces and a non-empty name — for example ar.tvplayer.tv=TiviMate. An icon, if given, cannot contain spaces.",
"unknown_app_row": "That app is no longer in the list. Pick one of the rows offered, or add it again.",
"invalid_intent_row": "The intent was not saved — nothing was changed. The next screen says what needs fixing, after the note describing what you were editing.",
"unknown_intent_row": "That intent is no longer registered. Pick one of the rows offered, or add it again.",
"invalid_intent_package": "That is not a usable package id. Use the exact id the Shield reports, for example com.netflix.ninja."
}, },
"abort": { "abort": {
"already_configured": "This Shield is already set up.", "already_configured": "This Shield is already set up.",
"reauth_successful": "Re-paired successfully.", "reauth_successful": "Re-paired successfully.",
"reauth_skipped": "Pairing skipped. The saved certificate is still not trusted, so the remote protocol stays unavailable until you pair again.", "reauth_skipped": "Pairing skipped. The saved certificate is still not trusted, so the remote protocol stays unavailable until you pair again.",
"reauth_entry_missing": "This entry no longer exists.", "reauth_entry_missing": "This entry no longer exists.",
"reconfigure_skipped": "Nothing changed \u2014 the remote protocol stays off until you pair it. Choose Reconfigure from this integration's entry menu when you are ready.", "reconfigure_skipped": "Nothing changed — the remote protocol stays off until you pair it. Choose Reconfigure from this integration's entry menu when you are ready.",
"reconfigure_entry_missing": "This entry no longer exists, so there is nothing to reconfigure.", "reconfigure_entry_missing": "This entry no longer exists, so there is nothing to reconfigure.",
"zeroconf_no_host": "The discovery event carried no usable address.", "zeroconf_no_host": "The discovery event carried no usable address.",
"yaml_seed_only": {}, "yaml_seed_only": {},
@@ -94,36 +147,272 @@
"step": { "step": {
"init": { "init": {
"title": "NVIDIA Shield settings", "title": "NVIDIA Shield settings",
"description": "Settings for this Shield. Saving reloads the integration so the changes take effect.", "description": "Settings for this Shield. Saving reloads the integration so the change takes effect. {discovery}.",
"data": { "data": {
"jellyfin_entity": "Jellyfin media player", "jellyfin_entity": "Jellyfin media player",
"receiver_entity": "Receiver", "receiver_entity": "Receiver",
"receiver_input": "Receiver input", "receiver_input": "Receiver input",
"power_entity": "Power switch", "power_entity": "Power switch",
"adb_enabled": "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 — no extra setup needed.",
"receiver_entity": "The receiver or soundbar this Shield plays through.", "receiver_entity": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch that playback turns on when it is off.",
"receiver_input": "The receiver input the Shield uses, for example 'Shield'.", "receiver_input": "The receiver input the Shield uses, for example 'Shield'.",
"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 — 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 — 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) — 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 — 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."
},
"menu_options": {
"connection": "Main setup",
"receiver": "Receiver",
"apps": "Apps",
"discover": "Discover installed apps",
"scripts": "Scripts",
"finish": "Save and close",
"intents": "Intents (buttons and send_intent)"
}
},
"connection": {
"title": "Main setup",
"description": "The Shield's address, the transports it is read over, and the devices this entry links to. Changes here are kept when you save.",
"data": {
"host": "The Shield's IP address",
"adb_port": "ADB port",
"adb_enabled": "Read state over ADB",
"adb_store_app_info": "Show title and artist",
"protocol_enabled": "Add the Nvidia Remote Protocol",
"logcat_events": "Watch app launches over logcat",
"adb_command_enabled": "Allow the adb_command action",
"screen_capture_enabled": "Add a screen capture image entity",
"jellyfin_entity": "Jellyfin media player",
"power_entity": "Power switch"
},
"data_description": {
"host": "The Shield's IP address. Change it only if the Shield moved.",
"adb_port": "The ADB port. The standard value is 5555 — change it only if ADB uses a custom port on your network.",
"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).",
"protocol_enabled": "The all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs the one-time PIN pairing from setup.",
"logcat_events": "Uses a second ADB stream to notice app launches the moment they happen. Leave off unless you want the faster app switch.",
"adb_command_enabled": "Exposes the adb_command action for this entry's media player. Off by default; it runs arbitrary shell commands on the Shield.",
"screen_capture_enabled": "Adds an image entity showing the Shield's screen on demand. Off by default: a capture is a full-resolution screen grab, it is a manual/diagnostic surface, and it never drives state.",
"jellyfin_entity": "The Jellyfin media_player for this Shield. Leave blank to detect it automatically.",
"power_entity": "A smart plug switch, if you want reliable on/off detection."
}
},
"receiver": {
"title": "Receiver",
"description": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch for a soundbar that only turns on and off. Playback turns a switch receiver on when it is off, so no receiver volume scripts are needed for that.",
"data": {
"receiver_entity": "Receiver",
"receiver_input": "Receiver input"
},
"data_description": {
"receiver_entity": "The receiver, soundbar or speaker switch this Shield plays through. Leave blank if there is none.",
"receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure."
}
},
"apps": {
"title": "Apps",
"description": "The apps this Shield offers: they fill the media player's source list and name the apps in automations. Add, edit or remove ONE app with the rows below, or paste the whole list as text. The list carries the apps discovery found as well as the rows you saved — a detected app is marked, and picking it lets you give it a name. Currently: {apps}",
"menu_options": {
"apps_text": "Edit the whole list as text",
"apps_add": "Add an app",
"apps_edit": "Edit an app",
"apps_remove": "Remove an app",
"menu_again": "Back to the main menu"
}
},
"apps_text": {
"title": "Apps as text",
"description": "One app per line: the package id, an equals sign, the name to show, and optionally a pipe and an icon (a picture URL or a camera entity id). Blank uses the built-in list. Icons the device reported: {icons} — the icon column itself takes a picture URL or a camera entity.",
"data": {
"app_name_map": "App names and icons"
},
"data_description": {
"app_name_map": "One row per app, for example ar.tvplayer.tv=TiviMate|http://192.168.1.10/tivi.png. The name is what the source list, the Current App sensor and the media player show."
}
},
"apps_add": {
"title": "Add an app",
"description": "Adds ONE row. The package id is what the Shield launches, the name is what the card, the sensor and the source list show.",
"data": {
"app_package": "Package id",
"app_name": "Name to show",
"app_icon": "Icon (optional)"
},
"data_description": {
"app_package": "The Android package id, for example ar.tvplayer.tv or com.netflix.ninja. No spaces.",
"app_name": "What the source list, the Current App sensor and the media player show for this app.",
"app_icon": "Optional: a picture URL or a camera entity id. Leave blank to show no picture."
}
},
"apps_edit": {
"title": "Edit an app",
"description": "Pick the app to edit: the rows you saved plus the apps discovery found (marked 'detected'). The next step edits the name and the icon, and saving a detected app stores it as one of your own rows.",
"data": {
"app_package": "App"
},
"data_description": {
"app_package": "Every app you can act on. '(no name yet)' means the device reported no label for it — pick it and type one. To change a package id itself, remove the row and add it again, or edit the whole list as text."
}
},
"apps_edit_form": {
"title": "Edit an app",
"description": "Editing {app}. The package id identifies the row and is not changed here.",
"data": {
"app_name": "Name to show",
"app_icon": "Icon (optional)"
},
"data_description": {
"app_name": "What the source list, the Current App sensor and the media player show for this app.",
"app_icon": "A picture URL or a camera entity id. Clear it to drop the picture."
}
},
"apps_remove": {
"title": "Remove an app",
"description": "Pick the app to remove. Nothing is removed until you confirm on the next step.",
"data": {
"app_package": "App"
},
"data_description": {
"app_package": "The rows saved so far. The confirmation is next."
}
},
"apps_remove_confirm": {
"title": "Remove an app",
"description": "Remove {app} from the list? It leaves the source list when you save, and the app itself stays installed on the Shield.",
"menu_options": {
"apps_remove_yes": "Remove it",
"apps_remove_no": "Keep it"
}
},
"scripts": {
"title": "Scripts",
"description": "Optional: run a Home Assistant script instead of the built-in command, per command. Clear a row to go back to the built-in path.",
"data": {
"remote_command_script": "Script for remote.send_command",
"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": "volume_mute script",
"script_select_source": "select_source script",
"channel_digit_delay": "Delay between channel digits (seconds)",
"channel_send_enter": "Send ENTER after channel digits"
},
"data_description": {
"remote_command_script": "Replaces the whole remote.send_command path for this Shield. Leave blank to use the built-in key presses.",
"channel_digit_delay": "The gap between the digits of a channel number, 0.05 to 1.0 seconds. Default 0.1.",
"channel_send_enter": "Turn on if your channel app needs ENTER after the digits."
}
},
"intents": {
"title": "Intents",
"description": "The intents this Shield can be asked to run: each one gets its own button entity, and `nvidia_shield.send_intent` sends one to it with variables. Register an app's own intents by scanning it, or enter one by hand. Currently: {intents}",
"menu_options": {
"intents_edit": "Add or edit an intent",
"intents_remove": "Remove an intent",
"intents_scan": "Scan an app's intents",
"menu_again": "Back to the main menu"
}
},
"intents_edit": {
"title": "Add or edit an intent",
"description": "Pick the intent to edit, or Add a new intent. Everything an intent needs is on the next screen: what to launch, and which values it takes.",
"data": {
"intent_choice": "Intent"
},
"data_description": {
"intent_choice": "The intents you have registered, or 'Add a new intent'. A registered intent is stored in this entry's options and survives a restart."
}
},
"intents_form": {
"title": "The intent",
"description": "One intent: where it goes, and what a caller may pass. This is {intent_note}.",
"data": {
"intent_name": "Name",
"intent_package": "Package id",
"intent_activity": "Activity (component)",
"intent_action": "Action",
"intent_categories": "Categories",
"intent_data": "Data (URL template)",
"intent_fields": "Variables (declared fields)"
},
"data_description": {
"intent_name": "What the button and the action's dropdown call it, for example 'Netflix: open a title'. Names must be unique in this entry.",
"intent_package": "The app's Android package id, exactly as the Shield reports it, for example com.netflix.ninja.",
"intent_activity": "The component to launch, for example com.netflix.ninja/.MainActivity. The slash is required. Use this OR the action below — an intent with neither cannot be sent.",
"intent_action": "An Android action such as android.intent.action.VIEW. Use this when the app matches the intent itself instead of a fixed screen.",
"intent_categories": "Optional categories, separated by commas, for example android.intent.category.DEFAULT.",
"intent_data": "Optional URL or URI to send. It may use the declared variables in braces, for example https://www.netflix.com/watch/{id} — the value is substituted as plain text and sent quoted, never evaluated.",
"intent_fields": "One variable per line as key:type, where type is string, int, bool or raw. Add :required to make it mandatory (the action then raises, naming it), or =value to give it a default (the button sends the default). Example: id:string:required — or quality:string=high."
}
},
"intents_remove": {
"title": "Remove an intent",
"description": "Pick the intent to remove. Its button entity goes away with it; nothing else on this entry changes.",
"data": {
"intent_choice": "Intent"
},
"data_description": {
"intent_choice": "The intent to remove from this entry's options."
}
},
"intents_remove_confirm": {
"title": "Remove this intent?",
"description": "Remove {intent}? Its button ({button}) disappears, and nvidia_shield.send_intent stops accepting the name. Nothing is sent to the Shield either way.",
"menu_options": {
"intents_remove_yes": "Yes, remove it",
"intents_remove_no": "No, keep it"
}
},
"intents_scan": {
"title": "Scan an app's intents",
"description": "Ask the Shield which intents ONE app advertises, then pick one to register. This reads the device over ADB, so only the section it needs crosses the connection. Known apps: {apps}",
"data": {
"intent_scan_package": "App to scan"
},
"data_description": {
"intent_scan_package": "The app whose advertised intents to read. Pick one the Shield reported, or type its package id."
}
},
"intents_scan_result": {
"title": "Discovered intents",
"description": "{scan_note}",
"data": {
"intent_candidate": "Intent to register"
},
"data_description": {
"intent_candidate": "One advertisement the app registered: the action, where it points, and the component that handles it. Entries that look like internal hooks are marked and listed last; registering one is allowed."
} }
} }
} }
@@ -139,7 +428,25 @@
}, },
"entity_id": { "entity_id": {
"name": "Entity", "name": "Entity",
"description": "The Nvidia Shield media player to target." "description": "The Shield to run it on: its media_player entity. Select it in the target picker above, or pass entity_id (or the Shield's device_id) in YAML."
}
}
},
"send_intent": {
"name": "Send intent",
"description": "Send one of this Shield's registered intents over ADB. Register intents in the integration's options under Intents; the action returns the device's own output, and a rejected intent raises with its message.",
"fields": {
"intent": {
"name": "Intent",
"description": "Which registered intent to send, by the name the Intents screen shows."
},
"variables": {
"name": "Variables",
"description": "A value for each variable the intent declares. A declared field with a default may be omitted; a required one raises, naming it. Variables the intent does not declare are refused."
},
"entity_id": {
"name": "Entity",
"description": "The Shield to send it to: its media_player entity. Select it in the target picker above, or pass entity_id (or the Shield's device_id) in YAML."
} }
} }
} }
@@ -147,15 +454,79 @@
"exceptions": { "exceptions": {
"invalid_command": { "invalid_command": {
"title": "Empty command", "title": "Empty command",
"message": "The command field is empty \u2014 nothing was sent." "message": "The command field is empty — nothing was sent."
}, },
"unsupported_target": { "unsupported_target": {
"title": "Unsupported entity for adb_command", "title": "Unsupported entity for this action",
"message": "{entity_id} is not an adb_command participant (its entry must enable the adb_command_enabled option) \u2014 command not sent." "message": "{entity_id} is not a participant of {service} — nothing was sent."
}, },
"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 — 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 — 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}"
},
"no_target": {
"title": "No Shield selected",
"message": "Select the Shield to run this on: the target picker's entity (or pass entity_id / device_id in YAML). {service} sends nothing without a target."
},
"ambiguous_target": {
"title": "More than one Shield selected",
"message": "{entity_id} covers more than one {service} target — select exactly one Shield (one media_player entity). Nothing was sent."
},
"unknown_activity": {
"title": "Unknown activity",
"message": "'{activity}' is not one of this Shield's activities. Use a name from the remote's activity list (the Apps map names, or the built-in list when no map is set), or pass an app's package id / a detail link."
},
"activity_launch_failed": {
"title": "Activity could not launch",
"message": "'{activity}' was not launched: the remote protocol could not run the command. Check the remote entity's protocol_* attributes (protocol_state says whether the transport is connected) — nothing was sent."
},
"protocol_unavailable": {
"title": "Remote-protocol transport unavailable",
"message": "'{activity}' was not launched: this entry has no remote-protocol transport (the option is off, or the androidtvremote2 library is missing). Turn the transport on and pair it, then try again."
},
"unknown_intent": {
"title": "Unknown intent",
"message": "'{intent}' is not a registered intent of {entity_id}. Known intents: {known}. Register one in the integration's options under Intents."
},
"missing_intent_field": {
"title": "Missing variable",
"message": "'{intent}' needs a value for '{field}' — pass it in variables. Nothing was sent."
},
"unknown_intent_variable": {
"title": "Undeclared variable",
"message": "'{intent}' declares no field(s) {fields}, so nothing was sent. Declare them on the intent, or remove them from variables."
},
"invalid_intent_field": {
"title": "Wrong value type",
"message": "'{field}' expects a {type} value, but '{value}' is not one — '{intent}' was not sent."
},
"invalid_intent_variables": {
"title": "Variables must be a mapping",
"message": "'variables' must map field names to values for '{intent}' — nothing was sent."
},
"unresolved_intent_placeholder": {
"title": "Unfilled placeholder",
"message": "'{intent}' uses {{{placeholder}}} in its data, but no value was supplied for it — nothing was sent."
},
"intents_need_adb": {
"title": "ADB is required for intents",
"message": "Sending an intent to {entity_id} needs ADB: turn ADB on for this Shield in its options and make sure it is reachable. The remote protocol cannot send an intent — nothing was sent."
},
"intent_no_answer": {
"title": "The Shield did not answer",
"message": "The Shield did not answer for {entity_id} — the intent was not sent."
},
"intent_failed": {
"title": "The Shield refused the intent",
"message": "The Shield refused the intent: {message}. This is usually the registration: an app that no longer handles the action, a component renamed by an update, or a permission it does not have."
} }
}, },
"entity": { "entity": {
@@ -165,6 +536,14 @@
}, },
"now_playing": { "now_playing": {
"name": "Now playing" "name": "Now playing"
},
"installed_apps": {
"name": "Installed apps"
}
},
"image": {
"screen_capture": {
"name": "Screen capture"
} }
} }
} }
@@ -3,84 +3,137 @@
"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 — 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, soundbar or speaker switch this Shield plays through. Leave blank if there is none.",
"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 — 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 — 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." }
},
"menu": {
"title": "Finish setup",
"description": "Pair the transports and check anything else before the entry is created. {pairing}.",
"data": {
"host": "The Shield's IP address",
"adb_port": "ADB port",
"adb_enabled": "Read state over ADB",
"protocol_enabled": "Add the Nvidia Remote Protocol"
},
"data_description": {
"host": "The Shield's IP address. Pre-filled when the Shield was discovered — change it only if you need a different address.",
"adb_port": "The ADB port. The standard value is 5555 — change it only if ADB uses a custom port on your network.",
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
"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."
},
"menu_options": {
"receiver": "Receiver",
"connection": "Connection",
"adb_pair": "Set up ADB",
"protocol_pair": "Pair the remote protocol",
"finish": "Create the entry"
}
},
"receiver": {
"title": "Receiver",
"description": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch. Playback turns a switch receiver on when it is off.",
"data": {
"receiver_entity": "Receiver",
"receiver_input": "Receiver input"
},
"data_description": {
"receiver_entity": "The receiver, soundbar or speaker switch this Shield plays through. Leave blank if there is none.",
"receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure."
}
},
"connection": {
"title": "Connection",
"description": "The Shield's address, the ADB port and which transports this entry uses.",
"data": {
"host": "The Shield's IP address",
"adb_port": "ADB port",
"adb_enabled": "Read state over ADB",
"protocol_enabled": "Add the Nvidia Remote Protocol"
},
"data_description": {
"host": "The Shield's IP address. Pre-filled when the Shield was discovered — change it only if you need a different address.",
"adb_port": "The ADB port. The standard value is 5555 — change it only if ADB uses a custom port on your network.",
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
"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."
} }
}, },
"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 — 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 — 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."
} }
}, },
"protocol_pair": { "protocol_pair": {
"title": "Pair the remote protocol", "title": "Pair the remote protocol",
"description": "While this step is open the Shield shows a pairing code. Type it below and select Submit. If no code appears on the Shield, check the IP address below and select Submit again \u2014 pairing restarts and the Shield shows a new code. To set up without the remote protocol, turn on 'Continue without protocol pairing'.", "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 — 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 — 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 — it stays off until you pair it. Pair later from this integration's entry menu: choose Reconfigure."
} }
}, },
"reauth_confirm": { "reauth_confirm": {
"title": "Re-pair Nvidia Shield", "title": "Re-pair Nvidia Shield",
"description": "The Shield no longer trusts the saved pairing certificate, usually after a factory reset. Select Submit to pair again \u2014 the Shield then shows a code." "description": "The Shield no longer trusts the saved pairing certificate, usually after a factory reset. Select Submit to pair again — the Shield then shows a code."
} }
}, },
"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 — or turn on 'Continue without protocol pairing' to finish setup without it.",
"invalid_app_map": "Each line must be package=name with a non-empty package (no spaces) and a non-empty name \u2014 for example ar.tvplayer.tv=TiviMate.", "no_state_source": "No state source selected. Choose at least one: 'Add the Nvidia Remote Protocol' or 'Read state over ADB'.",
"invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds." "invalid_app_map": "The apps text was not saved — nothing was changed. {invalid_lines} Fix those lines and select Submit again, or clear the field to go back to the built-in list.",
"invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds.",
"invalid_app_row": "A row needs a package id with no spaces and a non-empty name — for example ar.tvplayer.tv=TiviMate. An icon, if given, cannot contain spaces.",
"unknown_app_row": "That app is no longer in the list. Pick one of the rows offered, or add it again.",
"invalid_intent_row": "The intent was not saved — nothing was changed. The next screen says what needs fixing, after the note describing what you were editing.",
"unknown_intent_row": "That intent is no longer registered. Pick one of the rows offered, or add it again.",
"invalid_intent_package": "That is not a usable package id. Use the exact id the Shield reports, for example com.netflix.ninja."
}, },
"abort": { "abort": {
"already_configured": "This Shield is already set up.", "already_configured": "This Shield is already set up.",
"reauth_successful": "Re-paired successfully.", "reauth_successful": "Re-paired successfully.",
"reauth_skipped": "Pairing skipped. The saved certificate is still not trusted, so the remote protocol stays unavailable until you pair again.", "reauth_skipped": "Pairing skipped. The saved certificate is still not trusted, so the remote protocol stays unavailable until you pair again.",
"reauth_entry_missing": "This entry no longer exists.", "reauth_entry_missing": "This entry no longer exists.",
"reconfigure_skipped": "Nothing changed \u2014 the remote protocol stays off until you pair it. Choose Reconfigure from this integration's entry menu when you are ready.", "reconfigure_skipped": "Nothing changed — the remote protocol stays off until you pair it. Choose Reconfigure from this integration's entry menu when you are ready.",
"reconfigure_entry_missing": "This entry no longer exists, so there is nothing to reconfigure.", "reconfigure_entry_missing": "This entry no longer exists, so there is nothing to reconfigure.",
"zeroconf_no_host": "The discovery event carried no usable address.", "zeroconf_no_host": "The discovery event carried no usable address.",
"yaml_seed_only": {}, "yaml_seed_only": {},
@@ -94,36 +147,272 @@
"step": { "step": {
"init": { "init": {
"title": "NVIDIA Shield settings", "title": "NVIDIA Shield settings",
"description": "Settings for this Shield. Saving reloads the integration so the changes take effect.", "description": "Settings for this Shield. Saving reloads the integration so the change takes effect. {discovery}.",
"data": { "data": {
"jellyfin_entity": "Jellyfin media player", "jellyfin_entity": "Jellyfin media player",
"receiver_entity": "Receiver", "receiver_entity": "Receiver",
"receiver_input": "Receiver input", "receiver_input": "Receiver input",
"power_entity": "Power switch", "power_entity": "Power switch",
"adb_enabled": "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 — no extra setup needed.",
"receiver_entity": "The receiver or soundbar this Shield plays through.", "receiver_entity": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch that playback turns on when it is off.",
"receiver_input": "The receiver input the Shield uses, for example 'Shield'.", "receiver_input": "The receiver input the Shield uses, for example 'Shield'.",
"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 — 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 — 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) — 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 — 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."
},
"menu_options": {
"connection": "Main setup",
"receiver": "Receiver",
"apps": "Apps",
"discover": "Discover installed apps",
"scripts": "Scripts",
"finish": "Save and close",
"intents": "Intents (buttons and send_intent)"
}
},
"connection": {
"title": "Main setup",
"description": "The Shield's address, the transports it is read over, and the devices this entry links to. Changes here are kept when you save.",
"data": {
"host": "The Shield's IP address",
"adb_port": "ADB port",
"adb_enabled": "Read state over ADB",
"adb_store_app_info": "Show title and artist",
"protocol_enabled": "Add the Nvidia Remote Protocol",
"logcat_events": "Watch app launches over logcat",
"adb_command_enabled": "Allow the adb_command action",
"screen_capture_enabled": "Add a screen capture image entity",
"jellyfin_entity": "Jellyfin media player",
"power_entity": "Power switch"
},
"data_description": {
"host": "The Shield's IP address. Change it only if the Shield moved.",
"adb_port": "The ADB port. The standard value is 5555 — change it only if ADB uses a custom port on your network.",
"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).",
"protocol_enabled": "The all-in-one remote-protocol transport (power, availability, app push and volume readout). It needs the one-time PIN pairing from setup.",
"logcat_events": "Uses a second ADB stream to notice app launches the moment they happen. Leave off unless you want the faster app switch.",
"adb_command_enabled": "Exposes the adb_command action for this entry's media player. Off by default; it runs arbitrary shell commands on the Shield.",
"screen_capture_enabled": "Adds an image entity showing the Shield's screen on demand. Off by default: a capture is a full-resolution screen grab, it is a manual/diagnostic surface, and it never drives state.",
"jellyfin_entity": "The Jellyfin media_player for this Shield. Leave blank to detect it automatically.",
"power_entity": "A smart plug switch, if you want reliable on/off detection."
}
},
"receiver": {
"title": "Receiver",
"description": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch for a soundbar that only turns on and off. Playback turns a switch receiver on when it is off, so no receiver volume scripts are needed for that.",
"data": {
"receiver_entity": "Receiver",
"receiver_input": "Receiver input"
},
"data_description": {
"receiver_entity": "The receiver, soundbar or speaker switch this Shield plays through. Leave blank if there is none.",
"receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure."
}
},
"apps": {
"title": "Apps",
"description": "The apps this Shield offers: they fill the media player's source list and name the apps in automations. Add, edit or remove ONE app with the rows below, or paste the whole list as text. The list carries the apps discovery found as well as the rows you saved — a detected app is marked, and picking it lets you give it a name. Currently: {apps}",
"menu_options": {
"apps_text": "Edit the whole list as text",
"apps_add": "Add an app",
"apps_edit": "Edit an app",
"apps_remove": "Remove an app",
"menu_again": "Back to the main menu"
}
},
"apps_text": {
"title": "Apps as text",
"description": "One app per line: the package id, an equals sign, the name to show, and optionally a pipe and an icon (a picture URL or a camera entity id). Blank uses the built-in list. Icons the device reported: {icons} — the icon column itself takes a picture URL or a camera entity.",
"data": {
"app_name_map": "App names and icons"
},
"data_description": {
"app_name_map": "One row per app, for example ar.tvplayer.tv=TiviMate|http://192.168.1.10/tivi.png. The name is what the source list, the Current App sensor and the media player show."
}
},
"apps_add": {
"title": "Add an app",
"description": "Adds ONE row. The package id is what the Shield launches, the name is what the card, the sensor and the source list show.",
"data": {
"app_package": "Package id",
"app_name": "Name to show",
"app_icon": "Icon (optional)"
},
"data_description": {
"app_package": "The Android package id, for example ar.tvplayer.tv or com.netflix.ninja. No spaces.",
"app_name": "What the source list, the Current App sensor and the media player show for this app.",
"app_icon": "Optional: a picture URL or a camera entity id. Leave blank to show no picture."
}
},
"apps_edit": {
"title": "Edit an app",
"description": "Pick the app to edit: the rows you saved plus the apps discovery found (marked 'detected'). The next step edits the name and the icon, and saving a detected app stores it as one of your own rows.",
"data": {
"app_package": "App"
},
"data_description": {
"app_package": "Every app you can act on. '(no name yet)' means the device reported no label for it — pick it and type one. To change a package id itself, remove the row and add it again, or edit the whole list as text."
}
},
"apps_edit_form": {
"title": "Edit an app",
"description": "Editing {app}. The package id identifies the row and is not changed here.",
"data": {
"app_name": "Name to show",
"app_icon": "Icon (optional)"
},
"data_description": {
"app_name": "What the source list, the Current App sensor and the media player show for this app.",
"app_icon": "A picture URL or a camera entity id. Clear it to drop the picture."
}
},
"apps_remove": {
"title": "Remove an app",
"description": "Pick the app to remove. Nothing is removed until you confirm on the next step.",
"data": {
"app_package": "App"
},
"data_description": {
"app_package": "The rows saved so far. The confirmation is next."
}
},
"apps_remove_confirm": {
"title": "Remove an app",
"description": "Remove {app} from the list? It leaves the source list when you save, and the app itself stays installed on the Shield.",
"menu_options": {
"apps_remove_yes": "Remove it",
"apps_remove_no": "Keep it"
}
},
"scripts": {
"title": "Scripts",
"description": "Optional: run a Home Assistant script instead of the built-in command, per command. Clear a row to go back to the built-in path.",
"data": {
"remote_command_script": "Script for remote.send_command",
"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": "volume_mute script",
"script_select_source": "select_source script",
"channel_digit_delay": "Delay between channel digits (seconds)",
"channel_send_enter": "Send ENTER after channel digits"
},
"data_description": {
"remote_command_script": "Replaces the whole remote.send_command path for this Shield. Leave blank to use the built-in key presses.",
"channel_digit_delay": "The gap between the digits of a channel number, 0.05 to 1.0 seconds. Default 0.1.",
"channel_send_enter": "Turn on if your channel app needs ENTER after the digits."
}
},
"intents": {
"title": "Intents",
"description": "The intents this Shield can be asked to run: each one gets its own button entity, and `nvidia_shield.send_intent` sends one to it with variables. Register an app's own intents by scanning it, or enter one by hand. Currently: {intents}",
"menu_options": {
"intents_edit": "Add or edit an intent",
"intents_remove": "Remove an intent",
"intents_scan": "Scan an app's intents",
"menu_again": "Back to the main menu"
}
},
"intents_edit": {
"title": "Add or edit an intent",
"description": "Pick the intent to edit, or Add a new intent. Everything an intent needs is on the next screen: what to launch, and which values it takes.",
"data": {
"intent_choice": "Intent"
},
"data_description": {
"intent_choice": "The intents you have registered, or 'Add a new intent'. A registered intent is stored in this entry's options and survives a restart."
}
},
"intents_form": {
"title": "The intent",
"description": "One intent: where it goes, and what a caller may pass. This is {intent_note}.",
"data": {
"intent_name": "Name",
"intent_package": "Package id",
"intent_activity": "Activity (component)",
"intent_action": "Action",
"intent_categories": "Categories",
"intent_data": "Data (URL template)",
"intent_fields": "Variables (declared fields)"
},
"data_description": {
"intent_name": "What the button and the action's dropdown call it, for example 'Netflix: open a title'. Names must be unique in this entry.",
"intent_package": "The app's Android package id, exactly as the Shield reports it, for example com.netflix.ninja.",
"intent_activity": "The component to launch, for example com.netflix.ninja/.MainActivity. The slash is required. Use this OR the action below — an intent with neither cannot be sent.",
"intent_action": "An Android action such as android.intent.action.VIEW. Use this when the app matches the intent itself instead of a fixed screen.",
"intent_categories": "Optional categories, separated by commas, for example android.intent.category.DEFAULT.",
"intent_data": "Optional URL or URI to send. It may use the declared variables in braces, for example https://www.netflix.com/watch/{id} — the value is substituted as plain text and sent quoted, never evaluated.",
"intent_fields": "One variable per line as key:type, where type is string, int, bool or raw. Add :required to make it mandatory (the action then raises, naming it), or =value to give it a default (the button sends the default). Example: id:string:required — or quality:string=high."
}
},
"intents_remove": {
"title": "Remove an intent",
"description": "Pick the intent to remove. Its button entity goes away with it; nothing else on this entry changes.",
"data": {
"intent_choice": "Intent"
},
"data_description": {
"intent_choice": "The intent to remove from this entry's options."
}
},
"intents_remove_confirm": {
"title": "Remove this intent?",
"description": "Remove {intent}? Its button ({button}) disappears, and nvidia_shield.send_intent stops accepting the name. Nothing is sent to the Shield either way.",
"menu_options": {
"intents_remove_yes": "Yes, remove it",
"intents_remove_no": "No, keep it"
}
},
"intents_scan": {
"title": "Scan an app's intents",
"description": "Ask the Shield which intents ONE app advertises, then pick one to register. This reads the device over ADB, so only the section it needs crosses the connection. Known apps: {apps}",
"data": {
"intent_scan_package": "App to scan"
},
"data_description": {
"intent_scan_package": "The app whose advertised intents to read. Pick one the Shield reported, or type its package id."
}
},
"intents_scan_result": {
"title": "Discovered intents",
"description": "{scan_note}",
"data": {
"intent_candidate": "Intent to register"
},
"data_description": {
"intent_candidate": "One advertisement the app registered: the action, where it points, and the component that handles it. Entries that look like internal hooks are marked and listed last; registering one is allowed."
} }
} }
} }
@@ -139,7 +428,25 @@
}, },
"entity_id": { "entity_id": {
"name": "Entity", "name": "Entity",
"description": "The Nvidia Shield media player to target." "description": "The Shield to run it on: its media_player entity. Select it in the target picker above, or pass entity_id (or the Shield's device_id) in YAML."
}
}
},
"send_intent": {
"name": "Send intent",
"description": "Send one of this Shield's registered intents over ADB. Register intents in the integration's options under Intents; the action returns the device's own output, and a rejected intent raises with its message.",
"fields": {
"intent": {
"name": "Intent",
"description": "Which registered intent to send, by the name the Intents screen shows."
},
"variables": {
"name": "Variables",
"description": "A value for each variable the intent declares. A declared field with a default may be omitted; a required one raises, naming it. Variables the intent does not declare are refused."
},
"entity_id": {
"name": "Entity",
"description": "The Shield to send it to: its media_player entity. Select it in the target picker above, or pass entity_id (or the Shield's device_id) in YAML."
} }
} }
} }
@@ -147,15 +454,79 @@
"exceptions": { "exceptions": {
"invalid_command": { "invalid_command": {
"title": "Empty command", "title": "Empty command",
"message": "The command field is empty \u2014 nothing was sent." "message": "The command field is empty — nothing was sent."
}, },
"unsupported_target": { "unsupported_target": {
"title": "Unsupported entity for adb_command", "title": "Unsupported entity for this action",
"message": "{entity_id} is not an adb_command participant (its entry must enable the adb_command_enabled option) \u2014 command not sent." "message": "{entity_id} is not a participant of {service} — nothing was sent."
}, },
"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 — 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 — 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}"
},
"no_target": {
"title": "No Shield selected",
"message": "Select the Shield to run this on: the target picker's entity (or pass entity_id / device_id in YAML). {service} sends nothing without a target."
},
"ambiguous_target": {
"title": "More than one Shield selected",
"message": "{entity_id} covers more than one {service} target — select exactly one Shield (one media_player entity). Nothing was sent."
},
"unknown_activity": {
"title": "Unknown activity",
"message": "'{activity}' is not one of this Shield's activities. Use a name from the remote's activity list (the Apps map names, or the built-in list when no map is set), or pass an app's package id / a detail link."
},
"activity_launch_failed": {
"title": "Activity could not launch",
"message": "'{activity}' was not launched: the remote protocol could not run the command. Check the remote entity's protocol_* attributes (protocol_state says whether the transport is connected) — nothing was sent."
},
"protocol_unavailable": {
"title": "Remote-protocol transport unavailable",
"message": "'{activity}' was not launched: this entry has no remote-protocol transport (the option is off, or the androidtvremote2 library is missing). Turn the transport on and pair it, then try again."
},
"unknown_intent": {
"title": "Unknown intent",
"message": "'{intent}' is not a registered intent of {entity_id}. Known intents: {known}. Register one in the integration's options under Intents."
},
"missing_intent_field": {
"title": "Missing variable",
"message": "'{intent}' needs a value for '{field}' — pass it in variables. Nothing was sent."
},
"unknown_intent_variable": {
"title": "Undeclared variable",
"message": "'{intent}' declares no field(s) {fields}, so nothing was sent. Declare them on the intent, or remove them from variables."
},
"invalid_intent_field": {
"title": "Wrong value type",
"message": "'{field}' expects a {type} value, but '{value}' is not one — '{intent}' was not sent."
},
"invalid_intent_variables": {
"title": "Variables must be a mapping",
"message": "'variables' must map field names to values for '{intent}' — nothing was sent."
},
"unresolved_intent_placeholder": {
"title": "Unfilled placeholder",
"message": "'{intent}' uses {{{placeholder}}} in its data, but no value was supplied for it — nothing was sent."
},
"intents_need_adb": {
"title": "ADB is required for intents",
"message": "Sending an intent to {entity_id} needs ADB: turn ADB on for this Shield in its options and make sure it is reachable. The remote protocol cannot send an intent — nothing was sent."
},
"intent_no_answer": {
"title": "The Shield did not answer",
"message": "The Shield did not answer for {entity_id} — the intent was not sent."
},
"intent_failed": {
"title": "The Shield refused the intent",
"message": "The Shield refused the intent: {message}. This is usually the registration: an app that no longer handles the action, a component renamed by an update, or a permission it does not have."
} }
}, },
"entity": { "entity": {
@@ -165,6 +536,14 @@
}, },
"now_playing": { "now_playing": {
"name": "Now playing" "name": "Now playing"
},
"installed_apps": {
"name": "Installed apps"
}
},
"image": {
"screen_capture": {
"name": "Screen capture"
} }
} }
} }
+194
View File
@@ -0,0 +1,194 @@
# Intent catalogue and registered actions (plan)
Scope: let the user discover the Android **intents** an installed app advertises, register
the ones worth having, and fire them from Home Assistant — one button per registered
intent, plus a `send_intent` action for the ones that need inputs.
Status: design agreed in principle by the user (2026-10-12). Not built. Companion docs:
`docs/MVP_PLAN.md` (architecture), `docs/RESEARCH_SHIELD.md` (device captures),
`docs/RESEARCH_IDEAS.md` (adjacent ideas).
**Decisions locked (user):**
- Registered **buttons**, set up inside our options menu, plus a **`send_intent`** action.
Verbatim: *"The registered buttons, pre-setup within our menu options and a send_intent
command seem the best method here. Less messy than the current adb command method of
doing that."*
- Discovery is **explicit** (the user scans an app), never an automatic sweep.
- YAML stays **seed-only** (the standing ruling: we load it only when no UI config exists).
---
## 1. Where we are today
| Surface | State |
|---|---|
| `media_player.play_media` with a URL | Works — sends a `VIEW` intent through the ADB path |
| `nvidia_shield.adb_command` | Works but is a raw escape hatch, and until t14 cannot even be aimed at the Shield from the UI |
| Intent **discovery** | Does not exist |
| Intent **registration** | Does not exist |
| `ha_adb_common/intent.py` | **Not Android intents** — it is `PlaybackIntent`, the optimistic-UI dataclass. The name collision is a readability trap worth fixing (§7.4) |
So the user's memory of "scanning the available commands for ADB between applications" is
a real gap, not something built and forgotten.
### What the device actually exposes (observed, one Shield, Android 11)
From the user's capture of `pm dump com.netflix.ninja` on 2026-10-12:
- the output is ~**2241 lines** and is a *whole* dumpsys — `DUMP OF SERVICE package:`,
then `activity`, `meminfo`, `procstats`, `usagestats`, `batterystats`;
- the package section begins with an **`Activity Resolver Table:`** listing, per
component/filter, the `Action:`, `Category:`, `Scheme:`, `Authority:` and `Path:` lines,
followed by `Receiver Resolver Table:` and `Service Resolver Table:`;
- Netflix's table contains real, usable entries (`cast:`, `http`/`https` with
`www.netflix.com` path globs, `nflx:`, `netflix:`, `MAIN` + `LEANBACK_LAUNCHER`) and a
large amount of internal noise (`com.netflix.action.DEBUG_*`, `androidx.*`,
`com.google.firebase.*`, `ConstraintProxy*`).
Everything in §2 depends on that shape. It is **observed on one firmware**, so the parser
must degrade honestly rather than assume.
---
## 2. Discovery
Source of truth: the app's **activity resolver table**.
Filter **on the device** — on this Shield an unfiltered `pm dump` ships 2241 lines over
ADB for one app:
```sh
pm dump <pkg> | sed -n '/Activity Resolver Table/,/Service Resolver Table/p'
```
with a bounded `grep -A` fallback when the device's `sed`/toolbox cannot do ranges. Keep
the transfer to the resolver section; the rest of the dump is never needed.
Rules:
- **One package at a time, on demand**, triggered by the user from the Intents sub-screen.
Never a sweep of every installed app (that is 20 × ~2241 lines on this device).
- **Cached** per package with a TTL, in the same spirit as the app-label cache; a scan
that *ran* and found nothing is a real answer, a scan whose transport failed is not
(the lesson from the discovery-cache defect).
- **Rank, do not hide.** Flag internal-looking filters (`DEBUG_*`, `androidx.*`,
`com.google.firebase.*`) rather than deleting them — the user decides what is worth
registering, and we do not get to guess.
A cheaper action-first query also exists (`cmd package query-activities --brief -a …`).
**Not verified on this device**; treat as a possible optimisation, not the primary path.
Candidates are deduplicated on (component, action, categories, scheme/authority/path).
---
## 3. Data model — a registered intent
```yaml
registered_intents:
- id: netflix_title # slug; also the button's key
name: Netflix — open title # what the user sees
package: com.netflix.ninja
activity: .MainActivity # either a component…
action: android.intent.action.VIEW # …or an action (+ categories)
categories: [android.intent.category.DEFAULT]
data: "https://www.netflix.com/watch/{id}" # URI template, optional
fields: # declared inputs, with defaults
- {key: id, type: string, required: true}
```
Mapping to `am start` (through the existing ADB path):
| Declared | `am start` |
|---|---|
| component | `-n com.netflix.ninja/.MainActivity` |
| action | `-a android.intent.action.VIEW` |
| categories | `-c …` (repeated) |
| data template | `-d <resolved>` |
| `type: string` | `--es key value` |
| `type: int` | `--ei key value` |
| `type: bool` | `--ez key value` |
Notes that matter:
- **No eval, no shell interpolation.** `{placeholder}` substitution is plain string
replacement, and every value is `shlex.quote`d before it reaches the device.
- A missing **required** field is a `ServiceValidationError` naming the field.
- An optional `raw` field type passes extras verbatim for advanced cases, documented as
**unvalidated** — the escape hatch, not the default.
---
## 4. Surfaces
1. **One button entity per registered intent** — `button.<name>`, pressing it runs the
intent with the declared defaults. This is the "register an action for each one" the
user asked for, and it is the whole point for no-input intents.
2. **`nvidia_shield.send_intent`** — `intent: <name>` (a dropdown of registered intents)
plus `variables: {…}` supplying that intent's declared fields. This is how per-intent
inputs arrive from automations, scripts and dashboards; it mirrors `script.turn_on`
with variables because HA offers no clean way to register a service per config entry.
The action **returns the device's `am start` output**, so a failure is visible.
3. **Intents sub-screen** in the options flow, the same idiom as the Apps screen:
*Add an intent* (pick package → pick a discovered candidate or enter one manually →
name it → declare its fields), *Edit*, *Remove*, *Scan an app's intents*.
4. **YAML seeding** — a `registered_intents:` block, honoured only when the UI holds
nothing (seed-only ruling).
---
## 5. Failure modes and honesty
- `am start` failures (unresolved intent, permission denied, activity gone after an app
update) **surface the device's own message** and raise; never a silent success.
- **ADB is required.** The protocol transport cannot send intents; when ADB is down the
error says so instead of failing obscurely.
- An unknown intent name raises, listing the known names.
- An intent that the app rejects is a **config** problem, and the docs say so plainly.
- Nothing registered ever runs by itself, and discovery never auto-registers anything.
---
## 6. Costs
- Discovery: one filtered dump per scanned package, cached.
- Invocation: one ADB shell per press.
- Storage: registered intents are ordinary options data.
---
## 7. Open questions
1. Does `send_intent` returning a response need `SupportsResponse.OPTIONAL` on our HA
floor? Verify against the real installed HA before building on it.
2. Typed fields (string/int/bool) for v1, or string-plus-raw only? Recommendation: typed,
since that is what makes the dropdown-plus-variables model pleasant.
3. Broadcasts/receivers (`am broadcast`) — the tables are right there — are **out of scope
for v1**; activities and actions only.
4. Rename `ha_adb_common/intent.py` → `playback_intent.py` so the new `intents.py` is not
confused with it? Small churn, real clarity gain.
5. Should discovery be exposed as a service as well as a UI screen? Recommendation: UI
only in v1.
6. `play_media(media_content_type="intent")`? Recommendation: **no** — the button and the
action are clearer, and play_media already carries the URL/VIEW case.
---
## 8. Proposed tickets
| Unit | Contents |
|---|---|
| Discovery | `ha_adb_common/intents.py` + the filtered on-device dump + parser + cache + tests, with a fixture cut from the user's real Netflix capture |
| Registration & invocation | Intents sub-screen, per-intent fields, button entities, `send_intent` action, mapping, docs, strings, tests |
| Review | Adversarial verification of both, including a rejected-intent and an ADB-down path |
---
## 9. Non-goals
- No automatic intent execution or auto-registration from discovery.
- No arbitrary shell passthrough in `send_intent` (`adb_command` remains that door).
- No broadcasts/receivers in v1.
- No extras beyond string/int/bool/raw.
- Not a replacement for the app source list or `play_media`.
+1 -1
View File
@@ -404,7 +404,7 @@ covers what the external pieces provided:
| External piece | What it provided | Now native (file) | | External piece | What it provided | Now native (file) |
|---|---|---| |---|---|---|
| `mediasession2mqtt` bridge sensors | per-app now-playing pushed over MQTT from the Shield's notification listener | ADB media-session polling + the foreground tier (`connection.py`, `media_player.py`); the `media_dag_now_playing` attribute carries `source: jellyfin \| media_session \| app` | | `mediasession2mqtt` bridge sensors | per-app now-playing pushed over MQTT from the Shield's notification listener | ADB media-session polling + the foreground tier (`connection.py`, `media_player.py`); the reading is published as separate top-level `now_playing_*` attributes, and `now_playing_via` names the tier (`jellyfin` \| `media_session` \| `app`) |
| Universal media player `media_dag_running_apps` template | app switcher list with playback states | `running_apps_from_tasks(..., last_known=...)` (`ha_adb_common/running_apps.py`) — enriched with `foreground`/`state`/`last_known_state`/`last_known_title` | | Universal media player `media_dag_running_apps` template | app switcher list with playback states | `running_apps_from_tasks(..., last_known=...)` (`ha_adb_common/running_apps.py`) — enriched with `foreground`/`state`/`last_known_state`/`last_known_title` |
| Universal player "currently in app X" template | fallback state for no-session apps | the foreground ladder tier (`media_player.py` state property, tier 3.5) | | Universal player "currently in app X" template | fallback state for no-session apps | the foreground ladder tier (`media_player.py` state property, tier 3.5) |
| External androidtv_remote entity for commands | remote keys | the protocol-backed `remote` entity (Phase 2) — the external entity remains only as the transitional last command tier, removed in Phase 4 | | External androidtv_remote entity for commands | remote keys | the protocol-backed `remote` entity (Phase 2) — the external entity remains only as the transitional last command tier, removed in Phase 4 |
+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")
+407 -5
View File
@@ -7,6 +7,7 @@ their entity module through the synthetic ``nsh`` package AFTER calling
""" """
import asyncio import asyncio
import datetime
import enum import enum
import sys import sys
import types import types
@@ -25,7 +26,10 @@ ENTITIES_STUB_NAMES = (
"homeassistant.components.media_player", "homeassistant.components.media_player",
"homeassistant.components.remote", "homeassistant.components.remote",
"homeassistant.components.button", "homeassistant.components.button",
"homeassistant.components.image",
"homeassistant.helpers", "homeassistant.helpers",
"homeassistant.helpers.service",
"homeassistant.helpers.config_validation",
"homeassistant.helpers.entity_registry", "homeassistant.helpers.entity_registry",
"homeassistant.helpers.device_registry", "homeassistant.helpers.device_registry",
"homeassistant.helpers.selector", "homeassistant.helpers.selector",
@@ -33,13 +37,82 @@ ENTITIES_STUB_NAMES = (
"homeassistant.helpers.redact", "homeassistant.helpers.redact",
"homeassistant.helpers.entity", "homeassistant.helpers.entity",
"homeassistant.helpers.entity_platform", "homeassistant.helpers.entity_platform",
"homeassistant.util",
"homeassistant.util.dt",
"nsh.helpers", "nsh.helpers",
"nsh.power", "nsh.power",
"nsh.jellyfin", "nsh.jellyfin",
"nsh.media_player", "nsh.media_player",
"nsh.diagnostics", "nsh.diagnostics",
"nsh.remote", "nsh.remote",
"nsh.image",
) )
#: t14: every runtime service description registered through the stub face below,
#: as ``(domain, service, schema)``. A test clears it to see only its own call.
SERVICE_SCHEMA_CALLS: list = []
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
class _Config(types.SimpleNamespace):
"""Selector configs keep their kwargs (real HA stores them as fields).
The stub needs them inspectable: which domains a selector offers is a
user-visible contract (t56 — the Receiver field accepts a switch), and a
lambda throwing the value away cannot pin it.
"""
selector_module.EntitySelectorConfig = _Config
selector_module.SelectSelector = lambda *args, **kwargs: None
selector_module.SelectSelectorConfig = _Config
selector_module.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
# t64: the apps table is a MULTILINE text field, and the row editor picks an
# app out of a select whose options are label/value pairs. Both are real HA
# shapes; ``SelectOptionDict`` is a TypedDict there (a plain dict at runtime).
selector_module.SelectOptionDict = dict
selector_module.TextSelector = TextSelectorStub
selector_module.TextSelectorConfig = _Config
class TextSelectorStub:
"""Stand-in for HA's TextSelector: accepts a string, rejects anything else.
Real HA validates with ``probatio.Schema(str)`` (so ``None`` and numbers are
Invalid) and carries ``multiline`` as a CONFIG flag — the flag is what makes
the browser render a textarea, which is the t64 fix, so it is inspectable
here the same way the entity selectors' domains are.
"""
def __init__(self, config=None):
self.config = config
def __call__(self, data):
if not isinstance(data, str):
raise ValueError("Value is not a string")
return data
class MediaPlayerState(enum.StrEnum): class MediaPlayerState(enum.StrEnum):
@@ -48,6 +121,8 @@ class MediaPlayerState(enum.StrEnum):
IDLE = "idle" IDLE = "idle"
PLAYING = "playing" PLAYING = "playing"
PAUSED = "paused" PAUSED = "paused"
STANDBY = "standby"
BUFFERING = "buffering"
class MediaPlayerEntityFeature(enum.IntFlag): class MediaPlayerEntityFeature(enum.IntFlag):
@@ -63,6 +138,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:
@@ -167,14 +243,68 @@ class MediaPlayerEntity:
return None return None
class RemoteEntityFeature(enum.IntFlag):
"""homeassistant.components.remote.RemoteEntityFeature (the real values)."""
LEARN_COMMAND = 1
DELETE_COMMAND = 2
ACTIVITY = 4
class RemoteEntity: class RemoteEntity:
"""The RemoteEntity surface HA reads, backed by the ``_attr_*`` convention.
t15: HA renders the app/activity picker off ``supported_features`` and reads
``activity_list``/``current_activity``; both are cached properties on the real
base, reading ``_attr_*``. The stub carries that contract (the same shape as the
ImageEntity face above) so a test can read what the entity publishes instead of
asserting against our own attribute names.
"""
_attr_activity_list: list | None = None
_attr_current_activity: str | None = None
_attr_supported_features = RemoteEntityFeature(0)
@property
def supported_features(self):
return self._attr_supported_features
@property
def activity_list(self):
return self._attr_activity_list
@property
def current_activity(self):
return self._attr_current_activity
def async_write_ha_state(self) -> None: def async_write_ha_state(self) -> None:
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 +437,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
@@ -320,6 +457,12 @@ def install() -> None:
core.HomeAssistant = object core.HomeAssistant = object
core.callback = lambda fn: fn core.callback = lambda fn: fn
core.CALLBACK_TYPE = object core.CALLBACK_TYPE = object
# t14: the adb_command registration asks for OPTIONAL response support (the
# handler returns the command's output). The stub keeps HA's three values
# distinct so a test can tell "optional" from "only".
core.SupportsResponse = types.SimpleNamespace(
NONE="none", OPTIONAL="optional", ONLY="only"
)
core.Event = object core.Event = object
sys.modules["homeassistant.core"] = core sys.modules["homeassistant.core"] = core
@@ -327,11 +470,21 @@ def install() -> None:
ha_const.EVENT_HOMEASSISTANT_STOP = "homeassistant_stop" ha_const.EVENT_HOMEASSISTANT_STOP = "homeassistant_stop"
ha_const.STATE_PLAYING = "playing" ha_const.STATE_PLAYING = "playing"
ha_const.STATE_PAUSED = "paused" ha_const.STATE_PAUSED = "paused"
# t61: a receiver switch is read and switched by the media player, so the
# two switch states have to exist with their real values.
ha_const.STATE_ON = "on"
ha_const.STATE_OFF = "off"
# t14: the adb_command target arrives as these two service-call fields (the UI's
# target picker merges the selection in as entity_id, an automation targeting a
# device sends device_id), so the entity service schema names them.
ha_const.ATTR_ENTITY_ID = "entity_id"
ha_const.ATTR_DEVICE_ID = "device_id"
ha_const.Platform = types.SimpleNamespace( ha_const.Platform = types.SimpleNamespace(
MEDIA_PLAYER="media_player", MEDIA_PLAYER="media_player",
SENSOR="sensor", SENSOR="sensor",
REMOTE="remote", REMOTE="remote",
BUTTON="button", BUTTON="button",
IMAGE="image",
) )
sys.modules["homeassistant.const"] = ha_const sys.modules["homeassistant.const"] = ha_const
@@ -354,6 +507,14 @@ def install() -> None:
remote = types.ModuleType("homeassistant.components.remote") remote = types.ModuleType("homeassistant.components.remote")
remote.RemoteEntity = RemoteEntity remote.RemoteEntity = RemoteEntity
# t15: the feature flag that makes HA offer the activity/app picker, the service
# field the picker submits, and the two attributes HA reads off the base class.
remote.RemoteEntityFeature = RemoteEntityFeature
remote.ATTR_ACTIVITY = "activity"
remote.RemoteEntityStateAttribute = types.SimpleNamespace(
ACTIVITY_LIST="activity_list",
CURRENT_ACTIVITY="current_activity",
)
sys.modules["homeassistant.components.remote"] = remote sys.modules["homeassistant.components.remote"] = remote
button = types.ModuleType("homeassistant.components.button") button = types.ModuleType("homeassistant.components.button")
@@ -364,17 +525,172 @@ def install() -> None:
sensor_platform.SensorDeviceClass = types.SimpleNamespace(APP="app") sensor_platform.SensorDeviceClass = types.SimpleNamespace(APP="app")
sys.modules["homeassistant.components.sensor"] = sensor_platform sys.modules["homeassistant.components.sensor"] = sensor_platform
# t66: homeassistant.components.image.ImageEntity — the slice the screen
# capture entity uses, carrying the REAL contract that matters here: the
# attributes HA reads (``content_type``, ``image_last_updated``), the real
# ``__init__(hass, verify_ssl=False)`` signature, ``async_image`` as the
# bytes seam, state writing as a no-op (the harness writes no states), and
# HA 2026.10's own ``_attr_should_poll = False`` ("No need to poll image
# entities"). A subclass that forgets its base ``__init__`` fails here too.
image_platform = types.ModuleType("homeassistant.components.image")
class _EntityFace:
"""The Entity surface HA reads, backed by the ``_attr_*`` convention.
The real ImageEntity inherits all of this from Entity; the stub carries
the properties so a test can READ what the entity publishes (naming,
device, polling, availability) without a state machine.
"""
_attr_available = True
_attr_should_poll = True
_attr_translation_key = None
_attr_unique_id = None
_attr_device_info = None
_attr_extra_state_attributes = None
def async_write_ha_state(self) -> None:
return None
@property
def available(self) -> bool:
return self._attr_available
@property
def should_poll(self) -> bool:
return self._attr_should_poll
@property
def translation_key(self):
return self._attr_translation_key
@property
def unique_id(self):
return self._attr_unique_id
@property
def device_info(self):
return self._attr_device_info
@property
def extra_state_attributes(self):
return self._attr_extra_state_attributes
class ImageEntity(_EntityFace):
_attr_content_type = "image/jpeg"
_attr_image_last_updated = None
_attr_should_poll = False
def __init__(self, hass, verify_ssl: bool = False) -> None:
self.hass = hass
@property
def content_type(self) -> str:
return self._attr_content_type
@property
def image_last_updated(self):
return self._attr_image_last_updated
async def async_image(self) -> bytes | None:
raise NotImplementedError
image_platform.ImageEntity = ImageEntity
sys.modules["homeassistant.components.image"] = image_platform
# t66: homeassistant.util.dt — the timestamp helper the image entity stamps
# ``image_last_updated`` with. ``utcnow()`` really is UTC-aware, as in HA.
ha_util = types.ModuleType("homeassistant.util")
ha_util.__path__ = []
dt_util = types.ModuleType("homeassistant.util.dt")
def _utcnow():
return datetime.datetime.now(datetime.UTC)
dt_util.utcnow = _utcnow
ha_util.dt = dt_util
sys.modules["homeassistant.util"] = ha_util
sys.modules["homeassistant.util.dt"] = dt_util
helpers = types.ModuleType("homeassistant.helpers") helpers = types.ModuleType("homeassistant.helpers")
# t14: ``async_set_service_schema`` publishes the runtime service DESCRIPTION —
# the thing the frontend renders the target picker from (HA's cache is what
# get_services serves). The stub records the call instead, so a test can assert
# what the integration told HA to offer for nvidia_shield.adb_command.
service = types.ModuleType("homeassistant.helpers.service")
def _stub_set_service_schema(hass, domain, service_name, schema):
SERVICE_SCHEMA_CALLS.append((domain, service_name, schema))
service.async_set_service_schema = _stub_set_service_schema
helpers.service = service
sys.modules["homeassistant.helpers.service"] = service
# t14: the entity service schema names its two target fields through
# ``cv.entity_ids`` / ``cv.ensure_list_csv``. The stub keeps HA's SHAPE (None ->
# [], text or list -> list, comma text -> split) without the real entity-id
# format check — that check belongs to the installed HA and is exercised by
# tests/test_adb_command_real_ha.py. The schema is not executed in the stub
# suite anyway: those tests call the handler directly.
config_validation = types.ModuleType("homeassistant.helpers.config_validation")
def _ensure_list(value):
if value is None:
return []
if isinstance(value, (list, tuple)):
return list(value)
return [value]
def _ensure_list_csv(value):
if isinstance(value, str):
return [part.strip() for part in value.split(",") if part.strip()]
return _ensure_list(value)
config_validation.ensure_list = _ensure_list
config_validation.ensure_list_csv = _ensure_list_csv
config_validation.entity_ids = _ensure_list_csv
helpers.config_validation = config_validation
sys.modules["homeassistant.helpers.config_validation"] = config_validation
entity_registry = types.ModuleType("homeassistant.helpers.entity_registry") entity_registry = types.ModuleType("homeassistant.helpers.entity_registry")
class _RegistryEntry:
"""The two attributes the integration reads off a registry entry (t14)."""
def __init__(self, entity_id):
self.entity_id = entity_id
self.domain = entity_id.split(".", 1)[0]
class _Registry: class _Registry:
entities: dict = {} entities: dict = {}
#: t14: ``device_id -> [entity_id, ...]``, what HA's registry knows about a
#: device. Seeded by the test that needs a device target; ``async_get``
#: keeps returning a fresh instance, so the binding lives on the class.
bindings: dict = {}
def async_get(self, entity_id): def async_get(self, entity_id):
return None return None
def _stub_entries_for_device(registry, device_id, include_disabled_entities=False):
"""t14: HA's device->entities helper is a MODULE-LEVEL function, not a method
on the registry (measured on 2026.10.1) — the stub mirrors that shape, because
a stub that put it on the class hid the AttributeError the real API raised.
"""
del registry, include_disabled_entities
return [_RegistryEntry(entry) for entry in _Registry.bindings.get(device_id, [])]
entity_registry.async_get = lambda hass: _Registry() entity_registry.async_get = lambda hass: _Registry()
entity_registry.async_entries_for_device = _stub_entries_for_device
# t14: how a test gives the stub registry a device -> entities binding (the real
# one is filled by HA's own entity registry) and how it takes it back out.
def _stub_bind_device(device_id, *entity_ids):
_Registry.bindings[device_id] = list(entity_ids)
entity_registry.bind_device = _stub_bind_device
entity_registry.reset_bindings = lambda: _Registry.bindings.clear()
device_registry = types.ModuleType("homeassistant.helpers.device_registry") device_registry = types.ModuleType("homeassistant.helpers.device_registry")
class _DeviceRegistry: class _DeviceRegistry:
@@ -400,7 +716,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 +771,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 +829,14 @@ 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",
# t66: the screen-capture platform is a platform like the others, so it
# loads here too (and the module-coverage sweep already walks every file)
"image",
"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:
@@ -487,12 +853,48 @@ def load_entity_modules() -> dict:
"media_player", "media_player",
"remote", "remote",
"diagnostics", "diagnostics",
"image",
) )
} }
class FakeState:
"""The fields entity code actually reads off an HA ``State`` (t61)."""
def __init__(self, entity_id, state, attributes=None):
self.entity_id = entity_id
self.state = state
self.attributes = dict(attributes or {})
class FakeStates:
"""A dict-backed stand-in for HA's StateMachine (t61).
Only the read shape entities use: ``get(entity_id)`` returns the state
object, or None for an entity that does not exist — exactly what the real
machine returns. ``set`` is a test's way of giving an entity a state.
"""
def __init__(self):
self._states: dict = {}
def set(self, entity_id, state, attributes=None):
self._states[entity_id] = FakeState(entity_id, state, attributes)
def get(self, entity_id):
return self._states.get(entity_id)
class FakeHass: class FakeHass:
"""Sized to entity unit tests: background tasks + no-op call_later.""" """Sized to entity unit tests: background tasks + no-op call_later.
``states`` is the real HomeAssistant shape (every hass has one), so entity
code that reads a linked entity works unmodified; it is empty until a test
sets something.
"""
def __init__(self):
self.states = FakeStates()
def async_create_task(self, coro): def async_create_task(self, coro):
return asyncio.create_task(coro) return asyncio.create_task(coro)
+101
View File
@@ -0,0 +1,101 @@
DUMP OF SERVICE package:
Activity Resolver Table:
Schemes:
cast: deca7fa com.netflix.ninja/.MainActivity filter fc483a1
Action: "com.google.cast.action.START"
Category: "android.intent.category.DEFAULT"
Scheme: "cast"
http: deca7fa com.netflix.ninja/.MainActivity filter fc483a1
Action: "android.intent.action.VIEW"
Category: "android.intent.category.DEFAULT"
Category: "android.intent.category.BROWSABLE"
Category: "android.intent.category.LEANBACK_LAUNCHER"
Category: "android.intent.category.LAUNCHER"
Scheme: "http"
Authority: "www.netflix.com": -1
Path: "PatternMatcher{GLOB: /title.*}"
Path: "PatternMatcher{GLOB: /watch.*}"
Path: "PatternMatcher{LITERAL, /browse}"
Path: "PatternMatcher{LITERAL, /home}"
Path: "PatternMatcher{GLOB: /deeplink.*}"
https: deca7fa com.netflix.ninja/.MainActivity filter fc483a1
Action: "android.intent.action.VIEW"
Category: "android.intent.category.DEFAULT"
Category: "android.intent.category.BROWSABLE"
Category: "android.intent.category.LEANBACK_LAUNCHER"
Category: "android.intent.category.LAUNCHER"
Scheme: "https"
Authority: "www.netflix.com": -1
Path: "PatternMatcher{GLOB: /title.*}"
Path: "PatternMatcher{GLOB: /watch.*}"
Path: "PatternMatcher{LITERAL, /browse}"
Path: "PatternMatcher{LITERAL, /home}"
Path: "PatternMatcher{GLOB: /deeplink.*}"
netflix: deca7fa com.netflix.ninja/.MainActivity filter fc483a1
Action: "android.intent.action.VIEW"
Category: "android.intent.category.DEFAULT"
Scheme: "netflix"
nflx: deca7fa com.netflix.ninja/.MainActivity filter fc483a1
Action: "android.intent.action.VIEW"
Category: "android.intent.category.DEFAULT"
Scheme: "nflx"
Non-Data Actions:
android.intent.action.MAIN:
deca7fa com.netflix.ninja/.MainActivity filter fc483a2
Action: "android.intent.action.MAIN"
Category: "android.intent.category.LEANBACK_LAUNCHER"
Category: "android.intent.category.LAUNCHER"
com.netflix.action.NETFLIX_KEY_START:
deca7fa com.netflix.ninja/.MainActivity filter fc483a3
Action: "com.netflix.action.NETFLIX_KEY_START"
com.netflix.action.DIAL_START:
deca7fa com.netflix.ninja/.MainActivity filter fc483a4
Action: "com.netflix.action.DIAL_START"
com.netflix.action.DEBUG_PUSH_NOTIFICATION:
deca7fa com.netflix.ninja/.debug.DebugReceiver filter fc483a5
Action: "com.netflix.action.DEBUG_PUSH_NOTIFICATION"
com.netflix.action.DEBUG_FORCE_EXIT:
deca7fa com.netflix.ninja/.debug.DebugReceiver filter fc483a6
Action: "com.netflix.action.DEBUG_FORCE_EXIT"
Receiver Resolver Table:
Non-Data Actions:
androidx.work.DiagnosticsReportReceiver:
deca7fa com.netflix.ninja/androidx.work.impl.diagnostics.DiagnosticsReceiver
com.google.firebase.MESSAGING_EVENT:
deca7fa com.netflix.ninja/com.google.firebase.messaging.FirebaseMessagingService
com.netflix.action.DEBUG_PUSH_NOTIFICATION:
deca7fa com.netflix.ninja/.push.DebugPushReceiver
Service Resolver Table:
Non-Data Actions:
com.google.firebase.INSTANCE_ID_EVENT:
deca7fa com.netflix.ninja/com.google.firebase.iid.FirebaseInstanceIdService
Provider Resolver Table:
Authorities:
com.netflix.ninja.androidx-startup:
deca7fa com.netflix.ninja/androidx.startup.InitializationProvider
Permissions:
Permission [com.netflix.ninja.C2D_MESSAGE]:
sourcePackage=com.netflix.ninja
Packages:
Package [com.netflix.ninja] (7f1a2b3c):
userId=10123
pkg=Package{7f1a2b3c com.netflix.ninja}
nonLocalizedLabel=null
labelRes=0x7f0f0d31
versionCode=61102 minSdk=21 targetSdk=29
versionName=6.11.2 build 3413
codePath=/data/app/com.netflix.ninja-1/base.apk
ConstraintProxy.BatteryBroadcastReceiver=
com.netflix.ninja/androidx.work.impl.constraints.trackers.BatteryChargingProxy
Activity Resolver Table: (nothing else listed for this package)
DUMP OF SERVICE activity:
ACTIVITY MANAGER PENDING INTENTS (dumpsys activity pending)
(nothing)
DUMP OF SERVICE meminfo:
Applications Memory Usage (in Kilobytes):
DUMP OF SERVICE procstats:
Process Stats:
DUMP OF SERVICE usagestats:
In-memory rollup:
DUMP OF SERVICE batterystats:
Battery Stats:
+636 -10
View File
@@ -30,7 +30,31 @@ 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``'s
``extra_state_attributes`` — a property today, so the number rotted). 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.
6. unreachable statements (t62 F2): a statement after
``return``/``raise``/``continue``/``break`` in the SAME block can never run.
``connection.py`` carried a full duplicated copy of the label-drain body
(docstring included) after its ``return cached`` — valid Python, dead, and
invisible to every other pass here, so it shipped. Pure AST, with its own
entry point ``scan_unreachable()``.
All three 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 any 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
@@ -79,8 +103,16 @@ SHIELD_DIR = (
HA_CALL_OWNERS: dict[str, tuple[str, str]] = { HA_CALL_OWNERS: dict[str, tuple[str, str]] = {
"async_update_reload_and_abort": ("homeassistant.config_entries", "ConfigFlow"), "async_update_reload_and_abort": ("homeassistant.config_entries", "ConfigFlow"),
"async_show_form": ("homeassistant.config_entries", "ConfigFlow"), "async_show_form": ("homeassistant.config_entries", "ConfigFlow"),
"async_create_entry": ("homeassistant.config_entries", "ConfigFlow"), # t57 UNIT 0a: the FLOW-DISPLAY API surface. These are the calls that decide
# what the user sees, and a wrong keyword here is not a style error — it is a
# TypeError the moment a user submits a form, on every path through it.
# `async_show_menu` takes NO `errors` (HA 2026.10.1: step_id, menu_options,
# sort, description_placeholders), which is precisely the bug this guards.
"async_show_menu": ("homeassistant.data_entry_flow", "FlowHandler"),
"async_show_progress": ("homeassistant.data_entry_flow", "FlowHandler"),
"async_show_progress_done": ("homeassistant.data_entry_flow", "FlowHandler"),
"async_abort": ("homeassistant.config_entries", "ConfigFlow"), "async_abort": ("homeassistant.config_entries", "ConfigFlow"),
"async_create_entry": ("homeassistant.config_entries", "ConfigFlow"),
"async_set_unique_id": ("homeassistant.config_entries", "ConfigFlow"), "async_set_unique_id": ("homeassistant.config_entries", "ConfigFlow"),
"_abort_if_unique_id_configured": ( "_abort_if_unique_id_configured": (
"homeassistant.config_entries", "homeassistant.config_entries",
@@ -92,17 +124,140 @@ HA_CALL_OWNERS: dict[str, tuple[str, str]] = {
"async_forward_entry_setups": ("homeassistant.config_entries", "ConfigEntries"), "async_forward_entry_setups": ("homeassistant.config_entries", "ConfigEntries"),
"async_unload_platforms": ("homeassistant.config_entries", "ConfigEntries"), "async_unload_platforms": ("homeassistant.config_entries", "ConfigEntries"),
"async_add_executor_job": ("homeassistant.core", "HomeAssistant"), "async_add_executor_job": ("homeassistant.core", "HomeAssistant"),
# t14: THE TARGET-BEARING REGISTRATION. `ServiceRegistry.async_register` takes no
# `target` (measured on 2026.10.1, and the reason the user's UI offered no Shield
# to select): the frontend's picker is rendered from the service DESCRIPTION,
# which `async_set_service_schema` publishes and which WINS over a services.yaml.
# Both names are resolved against the INSTALLED HA at scan time, so an API that
# is renamed or dropped is a finding here, not a log line on a user's install.
"async_register": ("homeassistant.core", "ServiceRegistry"),
# NOTE the owner is the MODULE, not a class: the description writer and the
# device-expansion helper are module-level functions. That matters — the
# device path first shipped as `registry.async_entries_for_device(...)`, which is
# a method that does not exist; the real-HA probe caught it, and this entry makes
# the API's existence a scanned fact instead of a runtime AttributeError.
"async_set_service_schema": ("homeassistant.helpers", "service"),
"async_entries_for_device": ("homeassistant.helpers", "entity_registry"),
} }
#: Non-vacuity floors — the ONE source of truth for the standalone entry point #: Non-vacuity floors — the ONE source of truth for the standalone entry point
#: and the pytest wrapper (t39 F1). They sit below the current walk (13 modules, #: and the pytest wrapper (t39 F1). They sit below the current walk (26 modules,
#: 60 imports, 35 checked arguments, 7 awaited HA calls) so a broken or #: 77 imports, 282 relative imports, 65 checked arguments, 6 awaited HA calls,
#: mis-targeted walk fails loudly instead of printing zeros and exiting 0. #: 1661 statement blocks) so a broken or mis-targeted walk fails loudly instead of
#: printing zeros and exiting 0.
#:
#: THE MARGIN POLICY (t20/O3 — the t19 review asked for this in writing). A floor
#: is a VACUITY-AND-BLINDNESS alarm, not a size ratchet: it must fail when the walk
#: stops COVERING something (a broken glob, a regex that stopped matching, a module
#: that stopped being parsed), and it must not fail because a legitimate change
#: removed code. The margins follow from that, per counter:
#:
#: * counters where ONE unit is one API OWNER (modules, imports, call_args): 2.
#: One owner may be refactored away; a whole CLASS of owners going unseen (the
#: t62 F2 case, where deleting `async_show_menu` from HA_CALL_OWNERS left the
#: sweep green) is what the 2 catches. This is the project's long-standing
#: canary (t62 wrote it as "two below the count"), now stated once instead of
#: being implied by each raise.
#: * relative_imports: ~2.5%. The count is dominated by package-relative imports
#: that legitimately churn when files move; a collapse (an unparsed module, a
#: stale glob) is orders of magnitude, not a handful.
#: * statement_blocks: ~2%. This counter moves in BULK with every edit, which is
#: exactly why a fixed small margin would be wrong here (the t19 complaint: at
#: 1300 vs 1322 a 23-statement refactor tripped it). A 2% band leaves a normal
#: refactor alone while a parse failure still shows up as a large drop.
#:
#: No band states a COUNT: the current walk is in the header above and printed by
#: every sweep run, so a figure in this prose can only rot — a previous revision
#: carried exactly such a figure for statement_blocks and it was stale within one
#: unit of work (t24/O3). The BANDS are the policy and are deliberately the only
#: numbers here; a floor, once re-derived from a re-measured walk, is named next to
#: the counter that owns it.
#: * awaited_calls: 1 (measured 6, floor 5 — DELIBERATE, see below). Stated
#: plainly: with six awaited HA calls a percentage margin is meaningless, so
#: this floor is the weakest one here. It is still worth having — a walk that
#: stopped visiting `await` sites at all trips it — but a deliberate removal of
#: an awaited call MUST lower this floor in the same commit, and that is the
#: reviewable act the gate exists for.
#:
#: t18/F3 asked whether these margins are deliberate and whether the counters were
#: genuinely updated rather than dodged. Both answers are yes, and they are recorded
#: HERE rather than in a task log: the t18 review measured awaited_calls at 6
#: against this floor of 5 and three other counters within 2 (modules 26/24,
#: imports 77/75, call_args 65/63 — each named in its own comment below), and the
#: verdict is that those ARE the policy margins above: 2 for owner-counters because
#: an owner really can be refactored away, 1 for awaited_calls because the counter
#: is too small to do better. The counters themselves were RAISED to the real sweep
#: when the code grew (20→24, 70→75, 235→275, 55→63, 1400→1620) and re-derived
#: twice since (t20: 1620→1615; t21: 1615→1617 after this unit's blocks), never
#: lowered to dodge a failure and never left above the walk.
#:
#: The rule for changing any of them: lower a floor in the SAME commit that removes
#: the code it counts, and never raise one without re-running the sweep first (a
#: floor above the walk is a gate that can never pass).
FLOORS: dict[str, int] = { FLOORS: dict[str, int] = {
"modules": 10, # t41: the sweep is recursive, so the floor sits above the top-level-only
"imports": 40, # count (13) — a regression to the shallow glob trips it.
"call_args": 20, # t17: raised 20 -> 24, just under the current 26.
"modules": 24,
# t15: raised from 40/100 to just under the current 76/206, because the remote
# parity work is an IMPORTS-and-coupling feature: `RemoteEntityFeature`,
# `ATTR_ACTIVITY`, the transport-state accessors and the shared app-directory
# helpers all arrive as imports, and none of them may vanish unnoticed.
# t17: raised 70 -> 75, just under the current 77 (the intents work imports
# the scan/status vocabulary into the flow and the engine into the platform).
"imports": 75,
# t16: raised 190 -> 195 -> 235, again just under the walk. The intent
# discovery module adds the package's exports to `ha_adb_common`'s surface and
# is imported through it by the manager, so the relative-import count is exactly
# where a new module must show up — a floor left behind would let the manager's
# import of it disappear unnoticed.
# t17: raised 235 -> 275, just under the current 282. The registered-intent
# model, its statuses and its helpers move between modules, so this is again
# exactly where a detached import must show up.
"relative_imports": 275,
# t62 F2: raised from 20 to just under the current 46. A floor's job is to
# notice an API OWNER going away, not only a broken walk: at 20, deleting
# `async_show_menu` from HA_CALL_OWNERS left 40 checked arguments and still
# looked green — only the pre-fix fixture tripped it. At 44 every owner that
# carries a real settlement argument is load-bearing (show_form, show_menu,
# abort, create_entry, update_entry). The floor sits two below the count so a
# one-call-site refactor does not redden the gate.
#
# t14: raised from 44 to just under the current 54, because the target-bearing
# registration added two owners (`async_register`'s `schema`/`supports_response`
# arguments and the module-level description writer). At 44 those owners could go
# away without the gate noticing; at 52 they are load-bearing.
#
# t15: raised from 52 to just under the current 57 — the options-flow pairing
# step is a real flow-display surface of its own (its own async_show_form with
# step_id/data_schema/errors), so those call sites must not be able to vanish.
# t17: raised 55 -> 63, just under the current 65 — the Intents screen adds
# real flow-display call sites of its own (its menu, its three forms and the
# scan's result step), none of which may vanish unnoticed.
"call_args": 63,
"awaited_calls": 5, "awaited_calls": 5,
# t62 F2: the unreachable-statement pass walks ~1300 statement blocks. A
# floor here means a pass that silently stopped parsing cannot look green.
# t15: raised 500 -> 1200 -> 1300, just under the walk, for the same reason the
# imports floor moved: the exhaustion pass covers the new surfaces too.
# t16: raised 1200 -> 1300 -> 1400: the parser, the cache and the manager's
# scan path are new statement blocks, and the unreachable pass must keep
# covering them.
# t17: raised 1400 -> 1620, just under the current 1637. The screen, the seed
# validator, the command builder and the button are new statement blocks, and
# the unreachable pass is what keeps covering them — which is the point of the
# floor: a pass that silently stopped parsing must not look green.
# t20: the walk is 1648 after the protocol lifecycle fixes (the tracked
# attempt, the cancel-and-await stop and the transition banner are new blocks),
# so the floor is re-derived from the CURRENT walk at the 2% band above.
# t21: the walk is 1650 (the ADB-off guard returns before the scan, and the
# unterminated-value check is a few blocks more), so the 2% band is re-derived
# again: 1617.
# t23: the walk is 1661 — the ADB-off gate is asked by every device path now
# (sweep, label probe, logcat, heartbeat, watchdog, delegation, the accessor),
# which is a dozen more statement blocks. Re-derived at the same 2% band: 1627.
# Raised, not lowered, because that is what the re-measured walk says.
"statement_blocks": 1627,
} }
#: The interpreter floor Home Assistant itself declares: PyPI metadata for #: The interpreter floor Home Assistant itself declares: PyPI metadata for
@@ -136,9 +291,10 @@ 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
statement_blocks: int = 0
def floor_violations(self, floors: dict[str, int] | None = None) -> list[str]: def floor_violations(self, floors: dict[str, int] | None = None) -> list[str]:
"""Counters below the non-vacuity floors — a sweep nobody should trust. """Counters below the non-vacuity floors — a sweep nobody should trust.
@@ -157,6 +313,8 @@ 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.statement_blocks} statement blocks, "
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 +324,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 +394,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 +810,254 @@ 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``'s
#: ``extra_state_attributes``, a property today (the old line number rotted).
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
# ------------------------------------------------------------ unreachable code
#: Statements that end the block they sit in: nothing AFTER one of these, in the
#: SAME block, can ever run. ``return``/``raise`` end the block outright;
#: ``continue``/``break`` end this pass through the enclosing loop, which is the
#: same thing for the statements that follow them there.
_TERMINAL_STATEMENTS = (ast.Return, ast.Raise, ast.Continue, ast.Break)
def _statement_blocks(node: ast.AST):
"""Every list-of-statements a node owns (bodies, ``orelse``, ``finalbody``)."""
for _field, value in ast.iter_fields(node):
if not isinstance(value, list) or not value:
continue
if all(isinstance(item, ast.stmt) for item in value):
yield value
def _scan_unreachable(tree: ast.AST, path: str, report: Report) -> None:
"""t62 F2: flag statements that sit after a terminal one in the same block.
Per-block on purpose: a statement in a DIFFERENT block (an ``else``, an
``except`` handler, a nested function) is not dead, and flagging those would
make this pass noise. What it does see is the shape that shipped twice — a
duplicated body left after ``return cached`` inside the same function
(connection.py's label drain) — which is valid Python, invisible to every
other gate here, and simply never executed.
"""
for node in ast.walk(tree):
for block in _statement_blocks(node):
report.statement_blocks += 1
for index, statement in enumerate(block[:-1]):
if not isinstance(statement, _TERMINAL_STATEMENTS):
continue
dead = block[index + 1]
report.findings.append(
Finding(
path,
dead.lineno,
"unreachable-statement",
f"{type(statement).__name__.lower()} at line "
f"{statement.lineno} ends this block, so this statement "
"can never run (duplicated or leftover code)",
)
)
break
def _scan_unreachable_tree(paths, report: Report) -> None:
"""Walk the tree and run the unreachable-statement pass over every file."""
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_unreachable(tree, file.name, report)
def scan_unreachable(paths=None) -> list[Finding]:
"""The unreachable-statement pass on its own (t62 F2) — the wrapper's proof."""
if paths is None:
paths = [SHIELD_DIR]
elif isinstance(paths, (str, Path)):
paths = [paths]
report = Report()
_scan_unreachable_tree(paths, report)
return report.findings
# ----------------------------------------------------------------------- scan # ----------------------------------------------------------------------- scan
@@ -455,6 +1074,13 @@ def scan(paths=None) -> Report:
paths = [paths] paths = [paths]
report = Report() report = Report()
# t40/t41/t62: pure-AST checks, so they run even without homeassistant —
# that is the whole point of a contract stubs cannot enforce (a relative
# import needs no library to be wrong, and dead code needs no API to be
# dead).
_scan_entity_contracts(paths, report)
_scan_package_imports(paths, report)
_scan_unreachable_tree(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 +1110,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))
+372
View File
@@ -0,0 +1,372 @@
"""t14: `nvidia_shield.adb_command` against a REAL Home Assistant install.
The user's report was that the Shield could not be SELECTED for the action, so he
ran his diagnostics through the deprecated ``androidtv`` integration. A stub suite
cannot settle that: the picker is whatever HA hands the browser, and "was the
command sent to the Shield I picked?" is decided by HA's own service registry,
schema validation and target merging.
What this file drives, on the installed HA (2026.10.1):
* the REAL ``hass.services`` registry — the registration our ``async_setup`` makes;
* the REAL websocket command the frontend calls (``get_services``), asserting the
payload carries ``target: {entity: [{domain: media_player}]}`` — that payload IS
the picker;
* the REAL ``hass.services.async_call`` with ``return_response=True`` — asserting
the output comes back under ``adb_response`` (the key the user's captures use)
and that a call without it behaves as before;
* the REAL entity registry, for the ``device_id`` shape an automation sends.
WHAT THIS FILE CANNOT PROVE: there is no browser here, so nothing about how the
picker LOOKS. It proves the wire contract — the registration, the metadata the UI
consumes, and the resolved call. Every test skips cleanly when homeassistant is not
installed (run it with ``--with 'homeassistant>=2026.10' --with adb-shell``).
"""
from __future__ import annotations
import importlib
import json
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
import pytest_asyncio
pytest.importorskip(
"homeassistant",
reason="the real service registry is the point of this file",
)
from homeassistant.components.websocket_api import commands as ws_commands # noqa: E402
from homeassistant.config_entries import ( # noqa: E402
SOURCE_USER,
ConfigEntries,
ConfigEntry,
)
from homeassistant.core import HomeAssistant # noqa: E402
from homeassistant.exceptions import ServiceValidationError # noqa: E402
from homeassistant.helpers import device_registry as device_registry # noqa: E402
from homeassistant.helpers import entity_registry as er # noqa: E402
from homeassistant.helpers.entity_registry import ( # noqa: E402
EntityRegistryItems,
EntityRegistrySettings,
)
from homeassistant.helpers.service import ( # noqa: E402
async_get_all_descriptions,
)
_ROOT = Path(__file__).resolve().parent.parent
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
DOMAIN = "nvidia_shield"
SERVICE = "adb_command"
ENTITY_ID = "media_player.shield"
class FakePoller:
"""The ADB surface the handler uses: one shell call, real values back."""
def __init__(self, output="ok", error=None):
self.output = output
self.error = error
self.calls: list[str] = []
async def shell(self, command):
self.calls.append(command)
if self.error is not None:
raise self.error
return self.output
@pytest_asyncio.fixture(name="ha")
async def ha_fixture(tmp_path):
"""A real HomeAssistant with the real registries and the real service registry.
No core bootstrap: the registries are filled directly with the two containers
core's loader fills (same objects), exactly as the t56 flow probe does — so an
EntitySelector / target expansion sees the entities the real one would.
"""
hass = HomeAssistant(str(tmp_path))
device_registry.async_setup(hass)
await device_registry.async_load(hass)
registry = er.async_get(hass)
registry.entities = EntityRegistryItems(hass)
registry.deleted_entities = {}
registry.settings = EntityRegistrySettings()
registry._entities_data = registry.entities.data
hass.data[er.DATA_REGISTRY] = registry
# the real entry manager: device_registry.async_get_or_create needs an entry it
# can look up, exactly as a real device registration does
hass.config_entries = ConfigEntries(hass, {})
hass.config_entries.async_schedule_reload = lambda entry_id: None
entry = ConfigEntry(
version=1,
minor_version=1,
domain=DOMAIN,
title="Shield",
data={},
options={},
source=SOURCE_USER,
unique_id=None,
subentries_data=(),
discovery_keys={},
)
object.__setattr__(entry, "_options", {})
hass.config_entries._entries[entry.entry_id] = entry
hass._shield_entry = entry
return hass
@pytest.fixture(name="integration")
def integration_fixture():
"""The integration package, imported by path under its real name."""
return importlib.import_module("custom_components.nvidia_shield")
@pytest.fixture(name="media_player_module")
def media_player_fixture():
return importlib.import_module("custom_components.nvidia_shield.media_player")
@pytest_asyncio.fixture(name="shield")
async def shield_fixture(ha, integration, media_player_module):
"""Set the integration up, and register ONE Shield as an adb_command target.
Yields the fake manager behind that entity, so a test can see exactly how many
commands reached the device.
"""
await integration.async_setup(ha, {})
poller = FakePoller()
manager = SimpleNamespace(adb_poller=poller, connected=True)
media_player_module._ADB_COMMAND_TARGETS[ENTITY_ID] = manager
try:
yield poller
finally:
media_player_module._ADB_COMMAND_TARGETS.pop(ENTITY_ID, None)
def _service_payload(descriptions):
return descriptions[DOMAIN][SERVICE]
class RecordingConnection:
"""The websocket connection shape ``handle_get_services`` needs."""
def __init__(self):
self.messages: list[dict] = []
def send_message(self, message):
self.messages.append(message)
def send_result(self, msg_id, payload):
self.messages.append({"id": msg_id, "type": "result", "result": payload})
async def test_the_registration_declares_a_response(ha, integration, shield):
"""t14: a service that answers must say so — and OPTIONAL keeps callers free."""
from homeassistant.core import SupportsResponse
service = ha.services.async_services()[DOMAIN][SERVICE]
assert service.supports_response is SupportsResponse.OPTIONAL
async def test_the_frontend_payload_carries_the_target_picker(ha, integration, shield):
"""THE fix: what the browser receives for this action must offer the Shield.
Driven through the real websocket command (``get_services``), because that is
the payload the UI builds its action form and target picker from.
"""
payload = await async_get_all_descriptions(ha)
description = _service_payload(payload)
# the entity target the picker is rendered from
assert description["target"] == {"entity": [{"domain": ["media_player"]}]}
# a response-bearing action: the UI can offer to return the output
assert description["response"] == {"optional": True}
# both fields survive the runtime description (the form would be empty without)
assert set(description["fields"]) == {"command", "entity_id"}
# ...and it serialises as JSON, which is what actually crosses the wire
assert json.loads(json.dumps(payload))[DOMAIN][SERVICE]["target"] == {
"entity": [{"domain": ["media_player"]}]
}
async def test_the_websocket_command_serves_the_same_payload(ha, integration, shield):
"""The exact command the frontend calls, not just the accessor behind it."""
connection = RecordingConnection()
# the command is decorated: it schedules the handler as a background task and
# returns None, which is exactly how the frontend's request is served
ws_commands.handle_get_services(ha, connection, {"id": 7, "type": "get_services"})
await ha.async_block_till_done()
# HA serialises the response to JSON BYTES before it reaches the socket, so this
# is the exact text the browser parses for the action form
assert len(connection.messages) == 1
message = json.loads(connection.messages[0])
assert message["id"] == 7
assert message["success"] is True
served = message["result"][DOMAIN][SERVICE]
assert served["target"] == {"entity": [{"domain": ["media_player"]}]}
assert served["response"] == {"optional": True}
assert set(served["fields"]) == {"command", "entity_id"}
async def test_a_call_returns_the_command_output(ha, integration, shield):
"""The response the user's captures read: ``adb_response``, the old key."""
response = await ha.services.async_call(
DOMAIN,
SERVICE,
{"command": "echo hi", "entity_id": ENTITY_ID},
blocking=True,
return_response=True,
)
assert response == {"adb_response": "ok"}
assert shield.calls == ["echo hi"]
async def test_a_call_from_the_ui_target_shape_returns_the_output(ha, integration, shield):
"""HA merges a picked target into the call as a LIST — accept it."""
response = await ha.services.async_call(
DOMAIN,
SERVICE,
{"command": "echo list", "entity_id": [ENTITY_ID]},
blocking=True,
return_response=True,
)
assert response == {"adb_response": "ok"}
assert shield.calls == ["echo list"]
async def test_a_call_without_the_response_still_works(ha, integration, shield):
"""Existing YAML callers: same data, no ``return_response`` — unchanged."""
result = await ha.services.async_call(
DOMAIN,
SERVICE,
{"command": "echo old", "entity_id": ENTITY_ID},
blocking=True,
)
assert result is None
assert shield.calls == ["echo old"]
async def test_the_device_target_shape_resolves_through_the_registry(
ha, integration, shield, media_player_module
):
"""An automation that targets the DEVICE sends ``device_id``: resolve it."""
device = device_registry.async_get(ha).async_get_or_create(
config_entry_id=ha._shield_entry.entry_id,
identifiers={(DOMAIN, "shield-device")},
name="Shield",
)
er.async_get(ha).async_get_or_create(
"media_player",
DOMAIN,
"uid_shield",
device_id=device.id,
suggested_object_id="shield",
)
response = await ha.services.async_call(
DOMAIN,
SERVICE,
{"command": "echo device", "device_id": device.id},
blocking=True,
return_response=True,
)
assert response == {"adb_response": "ok"}
assert shield.calls == ["echo device"]
async def test_no_target_is_an_error_naming_what_to_pass(ha, integration, shield):
"""Never a silent no-op: HA's own validation is not the only guard."""
with pytest.raises(ServiceValidationError) as err:
await ha.services.async_call(
DOMAIN, SERVICE, {"command": "echo x"}, blocking=True
)
assert err.value.translation_key == "no_target"
assert shield.calls == []
async def test_two_targets_send_nothing(ha, integration, shield, media_player_module):
"""A raw shell command is never broadcast to Shields the user did not pick."""
other = SimpleNamespace(adb_poller=FakePoller(), connected=True)
media_player_module._ADB_COMMAND_TARGETS["media_player.other"] = other
try:
with pytest.raises(ServiceValidationError) as err:
await ha.services.async_call(
DOMAIN,
SERVICE,
{"command": "echo x", "entity_id": [ENTITY_ID, "media_player.other"]},
blocking=True,
)
assert err.value.translation_key == "ambiguous_target"
assert shield.calls == []
assert other.adb_poller.calls == []
finally:
media_player_module._ADB_COMMAND_TARGETS.pop("media_player.other", None)
async def test_an_unknown_entity_is_an_error(ha, integration, shield):
"""Another integration's media player is not a Shield to run shell on."""
with pytest.raises(ServiceValidationError) as err:
await ha.services.async_call(
DOMAIN,
SERVICE,
{"command": "echo x", "entity_id": "media_player.someone_else"},
blocking=True,
)
assert err.value.translation_key == "unsupported_target"
assert shield.calls == []
async def test_an_unreachable_shield_is_an_error_not_an_empty_response(
ha, integration, shield, media_player_module
):
"""The acceptance's honesty clause: ADB down → error, not ``None`` back."""
media_player_module._ADB_COMMAND_TARGETS[ENTITY_ID] = SimpleNamespace(
adb_poller=None, connected=False
)
try:
with pytest.raises(ServiceValidationError) as err:
await ha.services.async_call(
DOMAIN,
SERVICE,
{"command": "echo x", "entity_id": ENTITY_ID},
blocking=True,
return_response=True,
)
assert err.value.translation_key == "adb_unavailable"
finally:
media_player_module._ADB_COMMAND_TARGETS.pop(ENTITY_ID, None)
async def test_a_failing_transport_surfaces_the_error(
ha, integration, shield, media_player_module
):
"""A transport failure mid-call propagates — never ``adb_response: None``."""
poller = FakePoller(error=ConnectionError("adb socket dead"))
media_player_module._ADB_COMMAND_TARGETS[ENTITY_ID] = SimpleNamespace(
adb_poller=poller, connected=True
)
try:
with pytest.raises(ConnectionError):
await ha.services.async_call(
DOMAIN,
SERVICE,
{"command": "echo x", "entity_id": ENTITY_ID},
blocking=True,
return_response=True,
)
finally:
media_player_module._ADB_COMMAND_TARGETS.pop(ENTITY_ID, None)
+121
View File
@@ -0,0 +1,121 @@
"""t14: the adb_command ACTION is selectable — the contract HA reads.
The user's report was that the Shield could not be selected for the
``nvidia_shield.adb_command`` action, so he ran his diagnostics through the
deprecated ``androidtv`` integration instead. The cause was measured against the
installed Home Assistant (2026.10.1), not guessed:
* ``ServiceRegistry.async_register`` takes no ``target`` — a target CANNOT be
declared at registration;
* the frontend's target picker comes from the service DESCRIPTION, which HA builds
from ``services.yaml`` or from ``homeassistant.helpers.service.async_set_service_schema``,
and the runtime description WINS;
* therefore THIS integration must publish a description carrying
``target: {entity: [{domain: media_player}]}`` — that is the picker.
This file pins the published contract itself, without any HA installed: the
description, and the copy in BOTH translation files. The registration and the
resolution are exercised in ``test_command_routing.py::TestAdbCommandService``
(stubs) and in ``tests/test_adb_command_real_ha.py`` (real HA).
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
_ROOT = Path(__file__).resolve().parent.parent
_SHIELD = _ROOT / "custom_components" / "nvidia_shield"
@pytest.fixture(name="const")
def const_fixture():
"""The integration's const module, loaded by path like every other test."""
import sys
import conftest
if "nsh.const" not in sys.modules:
conftest._load_module("nsh.const", conftest._SHIELD_DIR / "const.py")
return sys.modules["nsh.const"]
def _strings(name: str) -> dict:
return json.loads((_SHIELD / name).read_text(encoding="utf-8"))
class TestThePublishedTarget:
"""What HA is told the action accepts."""
def test_the_target_is_the_shields_media_player_entity(self, const):
assert const.ADB_COMMAND_SERVICE_TARGET == {
"entity": [{"domain": const.ADB_COMMAND_TARGET_DOMAIN}]
}
assert const.ADB_COMMAND_TARGET_DOMAIN == "media_player"
def test_the_description_carries_the_target_and_the_two_fields(self, const):
description = const.ADB_COMMAND_SERVICE_DESCRIPTION
# the picker: without this key the UI offers no Shield at all
assert description["target"] is const.ADB_COMMAND_SERVICE_TARGET
assert set(description["fields"]) == {"command", "entity_id"}
assert description["fields"]["command"]["required"] is True
# a plain (non-required) field: the TARGET is what selects the Shield, and
# an entity_id given anyway still has to point at a participant
assert "required" not in description["fields"]["entity_id"]
def test_the_response_key_is_the_one_the_old_integration_returned(self, const):
# the user's captures read `adb_response`; keeping the name means a template
# written against `androidtv.adb_command` keeps working
assert const.ADB_COMMAND_RESPONSE_KEY == "adb_response"
class TestTheCopyMatchesBothTranslationFiles:
"""The description's prose is duplicated in the translations — so it cannot drift.
``strings.json``/``translations/en.json`` are where HA's translations live and
the description is what the frontend receives; the two copies must stay
identical, and every raised translation_key needs copy in BOTH files or the user
sees a bare key (t29's lesson, applied to t14's two new keys).
"""
@pytest.mark.parametrize("name", ["strings.json", "translations/en.json"])
def test_the_service_copy_matches_the_description(self, const, name):
service = _strings(name)["services"]["adb_command"]
description = const.ADB_COMMAND_SERVICE_DESCRIPTION
assert service["name"] == description["name"]
assert service["description"] == description["description"]
for field, copy in description["fields"].items():
assert service["fields"][field]["name"] == copy["name"]
assert service["fields"][field]["description"] == copy["description"]
@pytest.mark.parametrize("name", ["strings.json", "translations/en.json"])
def test_the_new_exception_keys_have_copy(self, const, name):
exceptions = _strings(name)["exceptions"]
for key in ("no_target", "ambiguous_target", "unsupported_target"):
assert exceptions[key]["title"], (name, key)
assert exceptions[key]["message"], (name, key)
# the placeholders the handler fills in are the ones the copy names
assert "{entity_id}" in exceptions["ambiguous_target"]["message"]
assert "{entity_id}" in exceptions["unsupported_target"]["message"]
assert "{service}" in exceptions["no_target"]["message"]
def test_both_files_are_identical(self):
# they are two copies of the same English copy — a divergence would mean one
# of them is dead weight
assert (_SHIELD / "strings.json").read_text(
encoding="utf-8"
) == (_SHIELD / "translations" / "en.json").read_text(encoding="utf-8")
def test_the_entity_field_copy_names_the_picker(self):
# the field the YAML caller passes has to say what to pass, now that the
# picker is the primary way in
for name in ("strings.json", "translations/en.json"):
copy = _strings(name)["services"]["adb_command"]["fields"]["entity_id"]
assert "media_player" in copy["description"]
assert "device_id" in copy["description"]
@@ -0,0 +1,646 @@
"""t23/O1 — with ADB switched off, NO path reaches the device.
The t22 review measured the harm: with a poller injected into an entry whose own
option said ``adb_enabled: False``, the installed-apps sweep sent **4** real
shells and a label probe sent **1** more. Both gated on the POLLER's absence,
which is a property of the setup code (``_build_poller_factory`` returns None when
ADB is off) — exactly the indirect guarantee the t21 repair had already proven
insufficient for the intent scan.
This file drives EVERY path in the connection manager that can touch the device
and asserts on the TRAFFIC (shells, polls, commands, connects), because the
traffic is the harm — a correct-looking status string is not the point. The
device side is a scripted double that would answer everything: no real Shield is
involved, so what is proven is that nothing was sent, not what firmware would do.
WHAT "NOTHING IS CACHED" MEANS EXACTLY (t24/O4): the ONE write a disabled path can
still make is a retry DEBT — ``_app_label_failed.add(package)`` in ``_dump_label``
— and a debt is not an answer: the label cache (``_app_labels``) stays untouched,
``app_label`` keeps returning None, and the debt is settled the moment the probe
finally runs with ADB back on. Asserted in
``test_the_only_write_while_off_is_a_retry_debt_not_an_answer``.
WHAT THIS FILE DOES NOT GATE (t25/O1): the paths where "ADB off must mean no
traffic" is deliberately NOT enforced, each listed in :data:`CARVE_OUTS` with its
mitigation and with the test that asserts that mitigation — the Pair ADB button's
press and ``ShieldConnectionManager.test_connect``. Naming them is the point: an
audit that silently excludes a path is not an audit, and a carve-out whose
assertion does not exist is prose.
"""
from __future__ import annotations
import asyncio
import contextlib
from pathlib import Path
from types import SimpleNamespace
import pytest
from nsh.connection import (
FIELD_AWAKE,
FIELD_FOREGROUND,
FIELD_RUNNING_APPS,
FIELD_SESSIONS,
ShieldConnectionManager,
)
from nsh.const import CONF_ADB_ENABLED, CONF_LOGCAT_EVENTS
from nsh.ha_adb_common import (
INTENT_SCAN_ADB_DISABLED,
INTENT_SCAN_ADB_UNAVAILABLE,
INTENT_SCAN_FRESH,
INTENT_SCAN_FRESH_EMPTY,
)
PKG = "com.netflix.ninja"
#: A minimal answer that IS a resolver section, so a scan that were allowed to run
#: would count as a real answer (nothing here fails for a second reason).
RESOLVER_SECTION = (
"Activity Resolver Table:\n"
" Schemes:\n"
" https:\n"
" deca7fa com.netflix.ninja/.MainActivity filter fc1\n"
' Action: "android.intent.action.VIEW"\n'
"Service Resolver Table:\n"
)
class WillingPoller:
"""A poller that answers EVERY call and records every one (t23/O1).
Nothing here refuses: if a path is still gated by the wrong fact, this double
will happily talk to "the device" and the shell/command count proves it.
"""
def __init__(self):
self.connected = True
self.awake = True
self.foreground = "app.a"
self.audio_dump = " package=app.a\n"
self.shell_calls: list[str] = []
self.poll_calls: list[str] = []
self.media_calls: list[str] = []
self.digit_calls: list[str] = []
self.foreground_sends: list[str] = []
self.enter_calls = 0
self.connect_calls = 0
self.disconnect_calls = 0
self.sessions = [SimpleNamespace(package="app.a", playing=True)]
# ── the transport ──
async def connect(self) -> bool:
self.connect_calls += 1
self.connected = True
return True
async def disconnect(self) -> None:
self.disconnect_calls += 1
self.connected = False
async def health_check(self) -> bool:
return self.connected
# ── the heartbeat ──
async def is_awake(self) -> bool | None:
self.poll_calls.append("is_awake")
return self.awake
async def poll_media_sessions(self, foreground_package=None, head_lines=200):
self.poll_calls.append("poll_media_sessions")
return self.sessions[0] if self.sessions else None
@property
def all_sessions(self):
return self.sessions
@property
def last_dump(self):
return self.audio_dump
async def poll_running_apps(self) -> list[dict]:
self.poll_calls.append("poll_running_apps")
return [{"package": "app.a", "name": "App A", "icon": None}]
async def poll_foreground(self) -> str | None:
self.poll_calls.append("poll_foreground")
return self.foreground
# ── anything a shell can reach ──
async def shell(self, command: str):
self.shell_calls.append(command)
if "Activity Resolver Table" in command:
return RESOLVER_SECTION
if "pm list packages" in command:
return f"package:{PKG}\n"
if "resolve-activity" in command:
return f"{PKG}/.MainActivity\n"
if "pm dump" in command:
return 'Application Label: "Netflix"\n'
return "ok\n"
async def send_media_command(self, command: str) -> bool:
self.media_calls.append(command)
return True
async def bring_to_foreground(self, package: str) -> bool:
self.foreground_sends.append(package)
return True
async def send_digit_keyevent(self, digit: str) -> bool:
self.digit_calls.append(digit)
return True
async def send_enter_keyevent(self) -> bool:
self.enter_calls += 1
return True
@property
def config(self):
return {"host": "shield.invalid", "port": 5555, "key_path": "/tmp/does-not-exist"}
@property
def traffic(self) -> list[str]:
"""EVERY device-touching call, in one list — the harm in one place (t23)."""
return [
*(f"shell:{call}" for call in self.shell_calls),
*(f"poll:{call}" for call in self.poll_calls),
*(f"media:{call}" for call in self.media_calls),
*(f"digit:{digit}" for digit in self.digit_calls),
*(f"foreground:{pkg}" for pkg in self.foreground_sends),
*(["enter"] * self.enter_calls),
*(["connect"] * self.connect_calls),
]
def _entry(*, adb_enabled: bool, **options):
"""The entry's OWN options decide — this is the fact every path must ask."""
return SimpleNamespace(
options={
CONF_ADB_ENABLED: adb_enabled,
# ON, so the logcat path is skipped for the OPTION's reason (ADB off)
# rather than for its own (a stream nobody asked for).
CONF_LOGCAT_EVENTS: True,
**options,
},
data={},
title="Shield",
entry_id="e1",
)
def _manager(poller, *, adb_enabled: bool) -> ShieldConnectionManager:
return ShieldConnectionManager(
hass=None, entry=_entry(adb_enabled=adb_enabled), poller=poller
)
#: THE AUDIT (t23/O1), as data: every path in the manager that can reach the
#: device, with the call that drives it. Each is run against a manager whose
#: option says ADB is off, and the poller's traffic must stay EMPTY.
AUDITED_PATHS = [
("intent scan", lambda m: m.async_scan_intents(PKG)),
("intent scan (forced)", lambda m: m.async_scan_intents(PKG, force=True)),
("installed-apps refresh", lambda m: m.async_refresh_installed_apps(force=True)),
("installed-apps refresh (non-forced)", lambda m: m.async_refresh_installed_apps()),
("app-label lookup", lambda m: _sync_label(m)),
("app-label drain (watchdog side)", lambda m: _drain(m)),
("adb text entry", lambda m: m.send_adb_input_text("hello")),
("media key send", lambda m: m.send_media_command("play")),
("bring to foreground", lambda m: m.bring_to_foreground(PKG)),
("digit key send", lambda m: m.send_digit_key("5")),
("enter key send", lambda m: m.send_enter_key()),
("watchdog cycle", lambda m: m._watchdog_cycle(5.0)),
("heartbeat refresh", lambda m: m._refresh()),
("logcat ensure", lambda m: _logcat(m)),
("logcat open", lambda m: _open_logcat(m)),
("logcat ladder", lambda m: m._run_logcat_stream()),
("watchdog start", lambda m: _start_watchdog(m)),
]
#: NAMED CARVE-OUTS (t25/O1) — the device paths this audit does NOT gate, each with
#: what it does, why that is safe, and the assertion that proves the mitigation. A
#: carve-out is a claim like every other claim here, so
#: ``test_every_carve_out_names_a_real_assertion`` checks that the named assertion
#: actually exists in the tree: a carve-out cannot point at a phantom.
CARVE_OUTS = (
{
"name": "Pair ADB button press (button.py::NvidiaShieldPairAdbButton)",
"what_it_does": (
"builds its OWN AdbMediaSessionPoller and connects it, and never reads "
"the entry's ADB option — MEASURED ctor/connect/disconnect 1/1/1 with "
"the option OFF, identical to option-ON (driven, not quoted)"
),
"why_safe": (
"there is no production route to the press with ADB off: "
"button.async_setup_entry adds that entity ONLY when the option is on, "
"so a disabled entry has no button to press; every options save runs "
"through NvidiaShieldOptionsFlow(OptionsFlowWithReload), which reloads "
"the entry and re-runs that setup, so turning ADB off takes the button "
"away; and a press is user-initiated — the same class as the "
"test_connect pairing probe below"
),
"asserted_by": (
"tests/test_intents_registration.py",
"test_no_pair_adb_button_while_adb_is_off",
),
"measured_by": (
"tests/test_intents_registration.py",
"test_the_press_does_not_consult_the_option_and_that_is_pinned",
),
"not_the_same_as": (
"the INTENT button, which CAN exist for an ADB-off entry and is NOT a "
"carve-out: it sends through run_registered_intent, which resolves the "
"poller through the gated adb_poller accessor (media_player.py:2317) and "
"refuses with `intents_need_adb` — zero traffic"
),
},
{
"name": "ShieldConnectionManager.test_connect (the config-flow pairing probe)",
"what_it_does": (
"opens a throwaway AdbMediaSessionPoller and connects, before any entry "
"exists, consulting no option at all"
),
"why_safe": (
"the setup menu offers that step only while the setup's own ADB option "
"is on (the menu row is built from _user_data[CONF_ADB_ENABLED]), and "
"the probe IS the user's explicit pairing action"
),
"asserted_by": (
"tests/test_adb_off_gates_every_device_path.py",
"test_the_pairing_probe_is_offered_only_while_the_option_is_on",
),
},
)
async def _maybe_await(value):
"""Drive an audited path: some are coroutines, some are plain calls (t23)."""
if asyncio.iscoroutine(value):
return await value
return value
def _sync_label(manager):
return manager.app_label(PKG)
async def _settle_label_tasks(manager):
"""Await any label fetch the drain started, so its shell WOULD have happened."""
for task in list(manager._tasks):
if task.get_name().startswith("app_label"):
with contextlib.suppress(Exception):
await task
async def _drain(manager):
"""The render path queues a label fetch, then the watchdog drains the queue."""
manager.app_label(PKG)
manager._drain_app_label_requests()
await _settle_label_tasks(manager)
def _logcat(manager):
manager._ensure_logcat_stream()
def _open_logcat(manager):
"""The logcat OPEN path: it raises while disabled, so swallow it here.
``test_the_logcat_open_refuses_with_the_disabled_message`` asserts the message;
this driver only has to prove the raise cost no traffic.
"""
with contextlib.suppress(RuntimeError):
manager._open_logcat_stream()
async def _start_watchdog(manager):
await manager.async_start()
await asyncio.sleep(0.01) # would be one cycle if the loop had started
await manager.async_stop()
class TestEveryAuditedPathSendsNothing:
"""ADB off + a poller that WOULD answer = zero traffic on every path."""
@pytest.mark.parametrize(
("name", "drive"), AUDITED_PATHS, ids=[name for name, _ in AUDITED_PATHS]
)
async def test_the_path_does_not_touch_the_device(self, name, drive):
poller = WillingPoller()
manager = _manager(poller, adb_enabled=False)
await _maybe_await(drive(manager))
await asyncio.sleep(0) # let any task the path started really run
assert poller.traffic == [], f"{name} reached the device: {poller.traffic}"
async def test_a_full_cycle_of_every_path_still_sends_nothing(self):
"""One manager, every path in sequence — the whole cycle, not one call."""
poller = WillingPoller()
manager = _manager(poller, adb_enabled=False)
# 1. the intent scan (t21's repair, asked again one layer deeper)
assert (await manager.async_scan_intents(PKG)).status == INTENT_SCAN_ADB_DISABLED
assert (
await manager.async_scan_intents(PKG, force=True)
).status == INTENT_SCAN_ADB_DISABLED
# 2. the installed-apps sweep — the 4-shell path the review measured
assert await manager.async_refresh_installed_apps(force=True) == []
assert manager.installed_apps() == []
# 3. the app-label probe — the 1-shell path, both entry points
assert manager.app_label(PKG) is None
manager._drain_app_label_requests()
await _settle_label_tasks(manager)
# 4. text entry and every key/command delegation
assert await manager.send_adb_input_text("hello") is False
assert await manager.send_media_command("play") is False
assert await manager.bring_to_foreground(PKG) is False
assert await manager.send_digit_key("5") is False
assert await manager.send_enter_key() is False
# 5. the heartbeat, the watchdog and the logcat stream
await manager._watchdog_cycle(5.0)
await _start_watchdog(manager)
manager._ensure_logcat_stream()
await asyncio.sleep(0)
# 6. the public accessor the OUT-OF-FILE consumers (adb_command service,
# screen capture, intent shell) read — it must refuse too
assert manager.adb_poller is None
assert poller.traffic == [], poller.traffic
async def test_nothing_is_cached_when_the_cycle_is_refused(self):
"""No cache write either: an unpermitted scan is not an answer."""
poller = WillingPoller()
manager = _manager(poller, adb_enabled=False)
await manager.async_scan_intents(PKG)
await manager.async_refresh_installed_apps(force=True)
manager.app_label(PKG)
manager._drain_app_label_requests()
await manager._refresh()
assert manager.intent_scan_state(PKG) is None, "no intent answer"
assert manager.installed_apps() == [], "no app table"
assert manager.installed_apps_stale is True, "nothing was stamped fresh"
assert manager.app_label_requests == set(), "nothing queued"
assert manager.app_label_inflight == set(), "nothing in flight"
assert manager.store.get(FIELD_AWAKE) is None, "no wake reading"
assert manager.store.get(FIELD_SESSIONS, []) == []
assert manager.store.get(FIELD_RUNNING_APPS, []) == []
assert manager.store.get(FIELD_FOREGROUND) is None
assert manager.awake is None
class TestTheWatchdogNeverConnects:
"""A TCP connect is device traffic too — the loop must not even start."""
async def test_the_watchdog_does_not_build_a_poller_or_connect(self):
ours = WillingPoller()
built = []
def factory():
built.append(1)
return ours
manager = ShieldConnectionManager(
hass=None,
entry=_entry(adb_enabled=False),
poller_factory=factory,
)
await manager.async_start()
await asyncio.sleep(0.02)
await manager.async_stop()
assert built == [], "the factory was never asked for a poller"
assert ours.connect_calls == 0, "and nothing was connected"
assert ours.traffic == []
async def test_a_direct_cycle_does_not_connect_either(self):
poller = WillingPoller()
poller.connected = False
manager = _manager(poller, adb_enabled=False)
await manager._watchdog_cycle(5.0)
assert poller.connect_calls == 0
assert poller.traffic == []
class TestTheOptionIsACheckNotALatch:
"""Turning ADB back on resumes normal work — no remembered decision."""
async def test_the_same_manager_resumes_when_the_option_flips(self):
poller = WillingPoller()
manager = _manager(poller, adb_enabled=False)
assert await manager.async_refresh_installed_apps(force=True) == []
assert manager.app_label(PKG) is None
assert await manager.send_media_command("play") is False
assert poller.traffic == [], "nothing while it is off"
manager.entry.options[CONF_ADB_ENABLED] = True # the user turns ADB on
rows = await manager.async_refresh_installed_apps(force=True)
assert rows, "the sweep really ran once ADB is on"
assert manager.app_label("com.other.app") is None # queued, not sent yet
assert manager.app_label_requests == {"com.other.app"}
assert await manager.send_media_command("play") is True
assert await manager.send_digit_key("5") is True
assert manager.adb_poller is poller
assert poller.shell_calls, "the device was reached again"
assert poller.media_calls == ["play"]
assert poller.digit_calls == ["5"]
async def test_the_intent_scan_resumes_too(self):
poller = WillingPoller()
manager = _manager(poller, adb_enabled=False)
assert (await manager.async_scan_intents(PKG)).status == INTENT_SCAN_ADB_DISABLED
manager.entry.options[CONF_ADB_ENABLED] = True
result = await manager.async_scan_intents(PKG)
assert result.status in (INTENT_SCAN_FRESH, INTENT_SCAN_FRESH_EMPTY)
assert result.answered is True
assert manager.intent_scan_state(PKG) is not None, "the answer is cached now"
class TestTheOtherStatusesAreUntouched:
"""ADB ON with no poller is 'unavailable', never 'disabled' (t21's split)."""
async def test_no_poller_reads_as_unavailable(self):
manager = _manager(None, adb_enabled=True)
assert (await manager.async_scan_intents(PKG)).status == (
INTENT_SCAN_ADB_UNAVAILABLE
)
assert await manager.async_refresh_installed_apps(force=True) == []
assert manager.installed_apps_status == "unknown"
assert await manager.send_media_command("play") is False
async def test_adb_off_and_no_poller_are_told_apart(self):
disabled = _manager(WillingPoller(), adb_enabled=False)
unbuilt = _manager(None, adb_enabled=True)
assert (await disabled.async_scan_intents(PKG)).status == (
INTENT_SCAN_ADB_DISABLED
)
assert (await unbuilt.async_scan_intents(PKG)).status == (
INTENT_SCAN_ADB_UNAVAILABLE
)
REPO_ROOT = Path(__file__).resolve().parent.parent
def _source(relative_path: str) -> str:
return (REPO_ROOT / relative_path).read_text(encoding="utf-8")
class TestTheNamedCarveOuts:
"""Every exclusion is a NAMED claim with an assertion behind it (t25/O1)."""
@pytest.mark.parametrize("carve_out", CARVE_OUTS, ids=[c["name"] for c in CARVE_OUTS])
def test_every_carve_out_names_a_real_assertion(self, carve_out):
"""No phantom carve-outs: the named assertion must exist in the tree."""
for key in ("asserted_by", "measured_by"):
pointer = carve_out.get(key)
if pointer is None:
continue
relative, name = pointer
assert f"def {name}(" in _source(relative), (
f"{carve_out['name']} claims {key}={relative}::{name}, which does "
"not exist"
)
assert carve_out["what_it_does"], "a carve-out states what it does"
assert carve_out["why_safe"], "a carve-out states its mitigation"
@pytest.mark.parametrize("carve_out", CARVE_OUTS, ids=[c["name"] for c in CARVE_OUTS])
def test_every_carve_out_is_outside_the_audited_table(self, carve_out):
"""A carve-out is an EXCLUSION — it must not also claim to be driven."""
driven = {name for name, _ in AUDITED_PATHS}
assert all(
carve_out["name"] not in driven_path for driven_path in driven
), carve_out["name"]
def test_the_pairing_probe_is_offered_only_while_the_option_is_on(self):
"""`test_connect` has no entry to ask, so the SETUP MENU is its gate."""
config_flow = _source("custom_components/nvidia_shield/config_flow.py")
menu_guard = (
"if self._user_data.get(CONF_ADB_ENABLED) and not getattr(\n"
' self, "_paired_adb", False\n'
" ):\n"
' options.append("adb_pair")'
)
assert menu_guard in config_flow, (
"the adb_pair row (the only route to the pairing probe) must stay "
"behind the setup's ADB option"
)
class TestTheLogcatPathsTheAuditNames:
"""The audit text names these two paths — so they are DRIVEN here (t25/O2)."""
async def test_the_logcat_open_refuses_with_the_disabled_message(self):
poller = WillingPoller()
manager = _manager(poller, adb_enabled=False)
with pytest.raises(RuntimeError, match="ADB is switched off"):
manager._open_logcat_stream()
assert poller.traffic == [], "the refusal cost no traffic"
async def test_the_logcat_ladder_returns_without_counting_a_failure(self):
"""A local option must not look like a device failure (t23, pinned t25)."""
poller = WillingPoller()
manager = _manager(poller, adb_enabled=False)
await manager._run_logcat_stream() # returns at once: nothing to consume
assert manager._logcat_failures == [], "no failure was recorded"
assert manager._logcat_disabled is False, "and the stream was not disabled"
assert poller.traffic == []
async def test_five_real_logcat_failures_still_auto_disable(self):
"""THE ANTI-OVERCORRECTION PIN: the ladder itself still works when ADB is on."""
poller = WillingPoller()
manager = _manager(poller, adb_enabled=True)
async def failing_pass():
raise RuntimeError("stream ended (device closed)")
async def no_sleep(_seconds):
return None
manager._logcat_pass = failing_pass
manager._logcat_ladder_sleep = no_sleep
await manager._run_logcat_stream()
assert manager._logcat_disabled is True, "five real failures still trip it"
assert manager._entry_options_logcat() is True, "the option itself is unchanged"
class TestTheOneWriteWhileOffIsDebt:
"""t25/O4: "nothing is cached" is exact because the one write is a DEBT."""
async def test_the_only_write_while_off_is_a_retry_debt_not_an_answer(self):
poller = WillingPoller()
manager = _manager(poller, adb_enabled=False)
# the one disabled call that reaches the probe directly (the watchdog drain
# and the public lookup are gated above it, so this is the route the review
# measured the write on)
await manager._fetch_app_label(PKG)
assert manager._app_labels == {}, "no label was CACHED — that is the claim"
assert manager._app_label_failed == {PKG}, "only the retry DEBT was recorded"
assert manager.app_label(PKG) is None, "and the lookup still answers nothing"
assert manager.app_label_inflight == set()
assert poller.traffic == [], "the debt cost no traffic"
manager.entry.options[CONF_ADB_ENABLED] = True # ADB is turned back on
await manager._fetch_app_label(PKG) # the owed retry finally runs
assert manager._app_labels.get(PKG) is not None, "the probe ran and cached"
assert manager._app_label_failed == set(), "and the debt was settled"
class TestAMissingOptionFailsClosed:
"""Where the `entry=None`-means-ADB-off behaviour is asserted (t25)."""
async def test_an_entry_with_no_adb_option_is_disabled(self):
"""No option recorded = OFF, so the harness change matched production."""
poller = WillingPoller()
entry = SimpleNamespace(options={}, data={}, title="Shield", entry_id="e1")
manager = ShieldConnectionManager(hass=None, entry=entry, poller=poller)
assert manager._adb_disabled() is True
assert manager._adb_active() is False
assert manager.adb_poller is None
assert await manager.send_media_command("play") is False
assert await manager.async_refresh_installed_apps(force=True) == []
assert manager.installed_apps() == []
assert poller.traffic == []
async def test_a_manager_with_no_entry_at_all_is_disabled_too(self):
"""The old harness shape (`entry=None`) is the same fact."""
poller = WillingPoller()
manager = ShieldConnectionManager(hass=None, entry=None, poller=poller)
assert manager._adb_disabled() is True
assert await manager.send_digit_key("5") is False
assert poller.traffic == []
def test_the_production_default_is_read_the_same_way(self):
"""`__init__.py` gates the poller on the same missing-option default."""
entry_source = _source("custom_components/nvidia_shield/__init__.py")
assert "if not entry.options.get(CONF_ADB_ENABLED, False):" in entry_source, (
"production treats a missing option as OFF, exactly like the manager"
)
File diff suppressed because it is too large. Load diff
+445
View File
@@ -0,0 +1,445 @@
"""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_ADB_ENABLED,
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]
)
# the harness's manager carries a realistic entry whose option says ADB is on
assert make_manager().entry.options[CONF_ADB_ENABLED] is True
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"]
+22 -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,
@@ -69,6 +69,19 @@ def _install_import_stubs() -> None:
def async_show_form(self, *, step_id, data_schema=None, errors=None): def async_show_form(self, *, step_id, data_schema=None, errors=None):
return _form(step_id, errors or {}) return _form(step_id, errors or {})
def async_show_menu(self, *, step_id=None, menu_options, sort=False,
description_placeholders=None):
# t56: the real MENU result shape; the flow manager dispatches the
# chosen `next_step_id` to `async_step_<choice>(None)`.
return {
"type": "menu",
"step_id": step_id,
"menu_options": list(menu_options),
"description_placeholders": dict(
description_placeholders or {}
),
}
def async_create_entry(self, *, title, data, options=None): def async_create_entry(self, *, title, data, options=None):
return {"type": "create_entry", "title": title, "data": data, return {"type": "create_entry", "title": title, "data": data,
"options": options} "options": options}
@@ -127,11 +140,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 +173,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 +197,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
@@ -222,6 +225,9 @@ async def test_pairing_submit_uses_declared_signature(mocked_test_connect):
mocked_test_connect.assert_awaited_once_with( mocked_test_connect.assert_awaited_once_with(
"shield.local", 5555, _KEY_PATH "shield.local", 5555, _KEY_PATH
) )
# t56: the pairing hands over to the setup menu; Finish creates the entry
assert result["type"] == "menu"
result = await flow.async_step_finish(None)
assert result["type"] == "create_entry" assert result["type"] == "create_entry"
+108 -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,
@@ -109,6 +110,19 @@ def _install_stubs() -> None:
def async_show_form(self, *, step_id, data_schema=None, errors=None): def async_show_form(self, *, step_id, data_schema=None, errors=None):
return _form(step_id, errors or {}) return _form(step_id, errors or {})
def async_show_menu(self, *, step_id=None, menu_options, sort=False,
description_placeholders=None):
# t56: the real MENU result shape; the flow manager dispatches the
# chosen `next_step_id` to `async_step_<choice>(None)`.
return {
"type": "menu",
"step_id": step_id,
"menu_options": list(menu_options),
"description_placeholders": dict(
description_placeholders or {}
),
}
def async_create_entry(self, *, title, data, options=None): def async_create_entry(self, *, title, data, options=None):
return {"type": "create_entry", "title": title, "data": data, return {"type": "create_entry", "title": title, "data": data,
"options": options} "options": options}
@@ -197,6 +211,7 @@ def _install_stubs() -> None:
SENSOR = "sensor" SENSOR = "sensor"
REMOTE = "remote" REMOTE = "remote"
BUTTON = "button" BUTTON = "button"
IMAGE = "image" # t66: the screen-capture platform lives in PLATFORMS
ha_const.Platform = _Platform ha_const.Platform = _Platform
sys.modules["homeassistant.const"] = ha_const sys.modules["homeassistant.const"] = ha_const
@@ -219,11 +234,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 +289,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 +302,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
@@ -348,9 +352,13 @@ class TestProtocolPairing:
assert client.finished_codes == [FAKE_PAIRING_CODE] assert client.finished_codes == [FAKE_PAIRING_CODE]
assert flow._unique_id == FAKE_MAC assert flow._unique_id == FAKE_MAC
assert client.disconnected == 1 assert client.disconnected == 1
# t56: a finished pairing hands over to the setup menu; its Finish row
# creates the entry
assert result["type"] == "menu"
result = await flow.async_step_finish(None)
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()
@@ -364,6 +372,8 @@ class TestProtocolPairing:
result = await flow.async_step_protocol_pair( result = await flow.async_step_protocol_pair(
{CONF_PAIRING_CODE: FAKE_PAIRING_CODE} {CONF_PAIRING_CODE: FAKE_PAIRING_CODE}
) )
assert result["type"] == "menu"
result = await flow.async_step_finish(None)
assert result["type"] == "create_entry" assert result["type"] == "create_entry"
assert client.finished_codes == [FAKE_PAIRING_CODE] assert client.finished_codes == [FAKE_PAIRING_CODE]
@@ -374,7 +384,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 +406,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 +434,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
File diff suppressed because it is too large. Load diff
+140 -13
View File
@@ -18,7 +18,12 @@ from nsh.connection import (
ShieldConnectionManager, ShieldConnectionManager,
StateStore, StateStore,
) )
from nsh.const import ADB_POLL_INTERVAL from nsh.const import (
ADB_POLL_INTERVAL,
APP_LABEL_EXIT_MARKER,
APP_LABEL_GREP_SHAPES,
CONF_ADB_ENABLED,
)
def test_connection_module_depends_on_no_homeassistant(): def test_connection_module_depends_on_no_homeassistant():
@@ -100,8 +105,28 @@ class FakePoller:
return self.connected return self.connected
def _adb_entry(**options):
"""A realistic entry: its own option says ADB is ON (t23/O1).
The manager is ALWAYS built with an entry in production, and an entry whose
options do not say ``adb_enabled: True`` means ADB is switched off — the gate
every device path now asks directly (``connection._adb_disabled``). A harness
that passed ``entry=None`` was therefore modelling an ADB-off entry while
expecting traffic to flow, which is exactly the combination the t22 review
measured. Tests that WANT the disabled behaviour build their own entry.
"""
return types.SimpleNamespace(
options={CONF_ADB_ENABLED: True, **options},
data={},
title="Shield",
entry_id="e1",
)
def _manager(poller=None, **kwargs) -> ShieldConnectionManager: def _manager(poller=None, **kwargs) -> ShieldConnectionManager:
return ShieldConnectionManager(hass=None, entry=None, poller=poller, **kwargs) return ShieldConnectionManager(
hass=None, entry=_adb_entry(), poller=poller, **kwargs
)
class TestStateStore: class TestStateStore:
@@ -146,7 +171,7 @@ class TestAppLabelCache:
"application-label-de: 'TiviMate DE'" "application-label-de: 'TiviMate DE'"
) )
manager = ShieldConnectionManager( manager = ShieldConnectionManager(
hass=None, entry=None, poller=poller, clock=(clock or (lambda: now)) hass=None, entry=_adb_entry(), poller=poller, clock=(clock or (lambda: now))
) )
return manager, now return manager, now
@@ -177,11 +202,18 @@ class TestAppLabelCache:
assert manager.app_label("com.unknown.app") is None assert manager.app_label("com.unknown.app") is None
await manager._watchdog_cycle(5.0) await manager._watchdog_cycle(5.0)
# still None (next tier), no crash — falls through cleanly # the dump RAN and carried no label: definitive negative, and the
assert manager.app_label("com.unknown.app") is not None or True # caller's next tier (the raw package) takes over — still None here,
# never a crash or a fabricated label
assert manager.app_label("com.unknown.app") is None
async def test_label_less_package_negatively_cached_no_repeat_dump(self): async def test_label_less_package_is_a_definitive_negative(self):
"""t26 F1: unresolvable packages cost at most one pm dump ever.""" """t24 F1: a label-less package's ANSWER costs one pm dump, not one/cycle.
The old code additionally stamped this answer on FAILURE paths; t55
separates the two (see the retry tests below), but the negative cache
for a dump that really ran stays — the round-trip tax must not return.
"""
manager, _t = self._manager_with_dump() manager, _t = self._manager_with_dump()
manager._poller.pm_dumps["com.unknown.app"] = "no label line here" manager._poller.pm_dumps["com.unknown.app"] = "no label line here"
@@ -201,6 +233,94 @@ class TestAppLabelCache:
assert len(manager._poller.shell_calls) == first_dump_count assert len(manager._poller.shell_calls) == first_dump_count
assert manager.app_label("com.unknown.app") is None # falls through assert manager.app_label("com.unknown.app") is None # falls through
async def test_failed_fetch_is_not_cached_and_retries(self):
"""t55 root cause: a fetch that could NOT run must not become a miss.
The live defect was a metadata fetch failing and the failure being
written as ("", stamp) — an eternal "no label" for the whole 24 h TTL,
which left app_name showing the raw package id with no way back. Here
the failed fetch stamps nothing, the next render re-enqueues, and the
retry resolves the real label.
"""
manager, _t = self._manager_with_dump()
real_shell = manager._poller.shell
calls: list[str] = []
async def _flaky(cmd):
calls.append(cmd)
if "pm dump" in cmd:
raise RuntimeError("adb shell blew up")
return await real_shell(cmd)
manager._poller.shell = _flaky
assert manager.app_label("ar.tvplayer.tv") is None # enqueue
await manager._watchdog_cycle(5.0)
import asyncio
await asyncio.sleep(0)
# the fetch ran and failed → NOTHING is stamped as an answer
assert manager._app_labels.get("ar.tvplayer.tv") is None
assert [c for c in calls if "pm dump" in c]
# the next render enqueues again (the miss is retried, not silenced)
assert manager.app_label("ar.tvplayer.tv") is None
assert manager.app_label_requests == {"ar.tvplayer.tv"}
# the retry reaches the device and resolves the label
manager._poller.shell = real_shell
await manager._watchdog_cycle(5.0)
await asyncio.sleep(0)
assert manager.app_label("ar.tvplayer.tv") == "TiviMate"
async def test_shell_failure_is_not_cached_and_retries(self):
manager, _t = self._manager_with_dump()
manager._poller.pm_dumps["ar.tvplayer.tv"] = "application-label: 'TiviMate'"
async def _boom(_cmd):
raise RuntimeError("adb shell blew up")
manager._poller.shell = _boom
manager.app_label("ar.tvplayer.tv")
await manager._watchdog_cycle(5.0)
import asyncio
await asyncio.sleep(0)
assert manager._app_labels.get("ar.tvplayer.tv") is None
assert manager.app_label_requests == set()
assert manager.app_label("ar.tvplayer.tv") is None
assert manager.app_label_requests == {"ar.tvplayer.tv"} # re-enqueued
async def test_in_flight_fetch_is_not_enqueued_twice(self):
manager, _t = self._manager_with_dump()
assert manager.app_label("ar.tvplayer.tv") is None
assert manager.app_label("ar.tvplayer.tv") is None # same render loop
assert manager.app_label_requests == {"ar.tvplayer.tv"}
assert manager.app_label_inflight == set()
async def test_one_fetch_per_cycle_even_under_a_burst(self):
manager, _t = self._manager_with_dump()
manager._poller.pm_dumps["com.a"] = "application-label: 'A'"
manager._poller.pm_dumps["com.b"] = "application-label: 'B'"
manager.app_label("com.a")
manager.app_label("com.b")
await manager._watchdog_cycle(5.0)
await manager._watchdog_cycle(5.0)
import asyncio
await asyncio.sleep(0)
dumps = [c for c in manager._poller.shell_calls if "pm dump" in c]
assert len(dumps) == 2 # one per cycle, both eventually resolved
assert sorted([manager.app_label("com.a"), manager.app_label("com.b")]) == [
"A",
"B",
]
async def test_pm_dump_command_is_shell_quoted(self): async def test_pm_dump_command_is_shell_quoted(self):
"""t26 F2: the package argument is shlex.quote'd (consistent).""" """t26 F2: the package argument is shlex.quote'd (consistent)."""
import shlex import shlex
@@ -215,9 +335,16 @@ class TestAppLabelCache:
await asyncio.sleep(0) # drain the fetch task await asyncio.sleep(0) # drain the fetch task
# t57: the probe carries the exit marker too, so a blank answer can be told
# from a FAILED fetch; the package is still quoted. t66: the label candidates
# are filtered ON THE DEVICE, and the 2241-line dump is never truncated —
# `head -300` cut it before the package section on the user's Shield.
assert manager._poller.shell_calls == [ assert manager._poller.shell_calls == [
f"pm dump {shlex.quote(wicked)} 2>/dev/null | head -300" f"pm dump {shlex.quote(wicked)} 2>/dev/null | "
f"grep -E {shlex.quote(APP_LABEL_GREP_SHAPES)} | head -40; "
f"echo {APP_LABEL_EXIT_MARKER}$?"
] ]
assert "head -300" not in manager._poller.shell_calls[0]
class TestLogcatStream: class TestLogcatStream:
@@ -230,9 +357,9 @@ class TestLogcatStream:
poller = FakePoller() poller = FakePoller()
poller.awake = True poller.awake = True
manager = ShieldConnectionManager( manager = ShieldConnectionManager(
hass=None, entry=None, poller=poller, clock=(clock or (lambda: now)) hass=None, entry=_adb_entry(), poller=poller, clock=(clock or (lambda: now))
) )
manager.entry = LEntry(options={"logcat_events": bool(lines)}) manager.entry = LEntry(options={"logcat_events": bool(lines), CONF_ADB_ENABLED: True})
lines = lines or [] lines = lines or []
async def _open(): async def _open():
@@ -326,9 +453,9 @@ class TestLogcatStream:
poller = FakePoller() poller = FakePoller()
poller.awake = True poller.awake = True
manager = ShieldConnectionManager( manager = ShieldConnectionManager(
hass=None, entry=None, poller=poller, clock=lambda: 1000.0 hass=None, entry=_adb_entry(), poller=poller, clock=lambda: 1000.0
) )
manager.entry = LEntry(options={"logcat_events": True}) manager.entry = LEntry(options={"logcat_events": True, CONF_ADB_ENABLED: True})
# the START record spans two stream lines (wrapped cmp=) # the START record spans two stream lines (wrapped cmp=)
lines = [ lines = [
@@ -731,7 +858,7 @@ class TestLifecycle:
poller = FakePoller() poller = FakePoller()
poller.awake = True poller.awake = True
manager = ShieldConnectionManager( manager = ShieldConnectionManager(
hass=None, entry=None, poller_factory=lambda: poller hass=None, entry=_adb_entry(), poller_factory=lambda: poller
) )
await manager.async_start() await manager.async_start()
await asyncio.sleep(0) await asyncio.sleep(0)
+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")) == []
+532
View File
@@ -12,9 +12,14 @@ 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 asyncio
import importlib
import json
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
@@ -41,6 +46,22 @@ PRE_FIX_SOURCES = {
" async def async_step_zeroconf(self, discovery_info):\n" " async def async_step_zeroconf(self, discovery_info):\n"
" self._async_handle_discovery_without_unique_id()\n" " self._async_handle_discovery_without_unique_id()\n"
), ),
# t57 UNIT 0a: a wrong kwarg on a FLOW-DISPLAY API. `async_show_menu` takes
# no `errors` in HA 2026.10.1, and the integration passed one — so every
# ordinary setup path raised TypeError at the moment a user submitted the
# first form, which is invisible to a stub suite whose stubs accept it.
"t57_show_menu_errors.py": (
"from homeassistant import config_entries\n"
"\n"
"\n"
"class PreFixSetupMenu(config_entries.ConfigFlow, domain='nvidia_shield'):\n"
" async def async_step_user(self, user_input=None):\n"
" return self.async_show_menu(\n"
" step_id='menu',\n"
" menu_options=['finish'],\n"
" errors={\'base\': \'nope\'},\n"
" )\n"
),
"t35_options_updates.py": ( "t35_options_updates.py": (
"from homeassistant import config_entries\n" "from homeassistant import config_entries\n"
"\n" "\n"
@@ -103,6 +124,7 @@ def test_the_sweep_flags_the_pre_fix_defects(tmp_path):
("d2_util_diagnostic.py", "broken-import"), ("d2_util_diagnostic.py", "broken-import"),
("d3_unawaited_discovery.py", "missing-await"), ("d3_unawaited_discovery.py", "missing-await"),
("t35_options_updates.py", "unknown-kwarg"), ("t35_options_updates.py", "unknown-kwarg"),
("t57_show_menu_errors.py", "unknown-kwarg"),
}, "\n" + str(report) }, "\n" + str(report)
@@ -206,3 +228,513 @@ 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()),
# t66: ``ImageEntity.__init__`` builds HA's shared httpx client, which
# registers a shutdown listener on ``hass.bus``. The fake hass carries
# just enough of that surface for the real base class to construct.
bus=SimpleNamespace(async_listen_once=lambda *args, **kwargs: (lambda: None)),
)
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",
),
(
"sensor",
"NvidiaShieldAppsSensor",
{"adb_enabled": True},
"sensor.shield_installed_apps",
),
("remote", "NvidiaShieldRemoteEntity", None, "remote.shield"),
("button", "NvidiaShieldPairAdbButton", None, "button.shield_pair_adb"),
(
"image",
"NvidiaShieldScreenImage",
{"screen_capture_enabled": True},
"image.shield_screen_capture",
),
)
#: 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)
# t15: ``app_source`` names the tier that produced the app identity, so the
# protocol push can never look like the ADB reading it outranked.
assert {"app_id", "app_name", "app_source"} <= set(attrs), sorted(attrs)
def test_the_remote_entity_publishes_the_activity_contract_under_real_ha():
"""t15: the app/activity picker contract, measured against real HA.
Core androidtv_remote's remote is the parity target. HA renders the picker from
``RemoteEntityFeature.ACTIVITY`` plus ``activity_list``/``current_activity``,
published through the base class's FINAL ``state_attributes`` — so this asserts
what the STATE MACHINE receives, not what our own attributes happen to say. The
transport evidence rides the same entity, which is what makes "paired?" and
"switched on?" answerable without a device test.
"""
_require_installed_homeassistant()
from homeassistant.components.remote import RemoteEntityFeature
entity, states = _real_ha_entity(
"remote",
"NvidiaShieldRemoteEntity",
options={
"app_name_map": {
"com.netflix.ninja": "Netflix",
"ar.tvplayer.tv": "TiviMate",
}
},
entity_id="remote.shield",
)
assert entity.supported_features == RemoteEntityFeature.ACTIVITY # exactly core's
assert entity.activity_list == ["Netflix", "TiviMate"]
assert entity.current_activity is None, "nothing is known yet — never fabricated"
_entity_id, _state, attrs = _write_ha_state(entity, states)
assert attrs["activity_list"] == ["Netflix", "TiviMate"]
assert attrs["current_activity"] is None
# ...and the transport's real state, on the same entity (no runtime_data here,
# so there is no transport at all — which must read as "off", not as unpaired)
assert attrs["protocol_state"] == "off"
assert attrs["protocol_connected"] is False
assert attrs["protocol_ever_authenticated"] is False
assert attrs["power_on_route"] == "protocol"
assert attrs["protocol_current_app_seen"] is False
# The LIVE update paths our entity uses: HA's own metaclass turns the class-level
# ``_attr_*`` declarations into properties backed by ``__attr_*`` plus cache
# invalidation, so both assignments must take effect on the published state. A
# private attribute name would leave ``activity_list``/``current_activity`` frozen
# at their first value — silent in a unit test, wrong in a dashboard.
entity._attr_current_activity = "Netflix"
entity._attr_activity_list = ["Netflix", "TiviMate", "YouTube"]
assert entity.current_activity == "Netflix"
assert entity.activity_list == ["Netflix", "TiviMate", "YouTube"]
_entity_id, _state, attrs = _write_ha_state(entity, states)
assert attrs["current_activity"] == "Netflix"
assert attrs["activity_list"] == ["Netflix", "TiviMate", "YouTube"]
#: The same real 2x1 PNG fixture tests/test_screen_capture_image.py uses (the
#: wire form ``screencap -p | base64`` produces) — a real PNG, not a stand-in.
_SCREEN_PNG_B64 = (
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAIAAAB7QOjdAAAAD0lEQVR42mP4z8DA8J8BAAf/"
"Af+4BDXgAAAAAElFTkSuQmCC"
)
def test_the_screen_capture_entity_serves_png_bytes_under_real_ha():
"""t66: HA's OWN image pieces accept what the capture path produces.
The stub suite proves the logic; this proves the contract with real HA: the
real ``ImageEntity`` base, HA's ``valid_image_content_type`` (what the image
proxy runs before it will serve bytes), and the real state write — whose
recorded state must be the capture TIMESTAMP and whose attributes must never
carry the image itself (the "never a state source" boundary, measured).
"""
_require_installed_homeassistant()
import base64
from homeassistant.components.image import valid_image_content_type
class _Poller:
connected = True
async def shell(self, cmd):
assert cmd == "screencap -p | base64"
return base64.b64encode(base64.b64decode(_SCREEN_PNG_B64)).decode()
entity, states = _real_ha_entity(
"image",
"NvidiaShieldScreenImage",
options={"screen_capture_enabled": True},
entity_id="image.shield_screen_capture",
)
entity._entry.runtime_data = SimpleNamespace(adb_poller=_Poller())
data = asyncio.run(entity.async_image())
assert isinstance(data, bytes)
assert data.startswith(b"\x89PNG\r\n\x1a\n")
assert valid_image_content_type(entity.content_type) == "image/png"
written_id, state, attrs = _write_ha_state(entity, states)
assert written_id == "image.shield_screen_capture"
# HA's image state IS ``image_last_updated`` — the timestamp, never the bytes
assert state == entity.image_last_updated.isoformat()
assert attrs["capture_status"] == "ok"
assert not any(isinstance(value, (bytes, bytearray)) for value in attrs.values())
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"
)
# ------------------------------------------------- t62: dead code and the floor
#
# Both defects pinned below shipped while every other gate was green:
# connection.py carried a duplicated copy of the label-drain body AFTER its
# `return cached` (valid Python, dead, invisible to this sweep), and the
# call-argument floor sat at 20 while the counts had moved to ~46, so deleting a
# flow-display API owner from HA_CALL_OWNERS did not trip it.
#: t62 F2: the shape that shipped — a bare docstring plus the whole drain left
#: after `return cached`, inside the same method. Line 6 is the dead statement.
UNREACHABLE_PRE_FIX = (
"class Sweep:\n"
" def labels(self, requests):\n"
" cached = {}\n"
" return cached\n"
"\n"
' """Start at most one label fetch per watchdog cycle."""\n'
" if not requests:\n"
" return\n"
" requests.discard(min(requests))\n"
)
def test_the_sweep_flags_the_pre_fix_unreachable_body(tmp_path):
"""t62 F2 red proof: the duplicated drain body after `return cached` is dead."""
(tmp_path / "dead_sweep.py").write_text(UNREACHABLE_PRE_FIX, encoding="utf-8")
findings = ha_api_drift.scan_unreachable(tmp_path)
assert [(finding.path, finding.kind) for finding in findings] == [
("dead_sweep.py", "unreachable-statement")
], findings
assert findings[0].lineno == 6 # the dead docstring, not the return
def test_every_terminal_statement_kind_is_flagged(tmp_path):
"""`return`, `raise`, `continue` and `break` all end a block (t62 F2)."""
(tmp_path / "terminals.py").write_text(
"def a():\n"
" return 1\n"
" x = 1\n"
"\n"
"\n"
"def b():\n"
" raise SystemExit(1)\n"
" y = 2\n"
"\n"
"\n"
"def c(items):\n"
" for item in items:\n"
" continue\n"
" z = 3\n"
" while True:\n"
" break\n"
" w = 4\n",
encoding="utf-8",
)
findings = ha_api_drift.scan_unreachable(tmp_path)
assert [finding.kind for finding in findings] == ["unreachable-statement"] * 4, findings
def test_the_production_tree_has_no_unreachable_statements():
"""The gate itself: dead code is a finding, so it cannot ship silently."""
assert ha_api_drift.scan_unreachable() == []
def test_the_unreachable_pass_walks_real_statement_blocks():
"""The floor proves "no findings" is not a pass that stopped parsing."""
report = ha_api_drift.scan()
assert report.statement_blocks >= ha_api_drift.FLOORS["statement_blocks"]
def test_removing_a_flow_display_owner_trips_the_call_argument_floor(monkeypatch):
"""t62: the floor must notice an API OWNER going away, not just a bad walk."""
_require_installed_homeassistant()
intact = ha_api_drift.scan()
assert intact.floor_violations() == []
reduced = dict(ha_api_drift.HA_CALL_OWNERS)
del reduced["async_show_menu"] # the t57 UNIT 0a owner: 6 checked arguments
monkeypatch.setattr(ha_api_drift, "HA_CALL_OWNERS", reduced)
fewer = ha_api_drift.scan()
assert fewer.call_args < intact.call_args
assert fewer.call_args < ha_api_drift.FLOORS["call_args"], (
f"removing async_show_menu left {fewer.call_args} checked arguments, "
f"still at or above the floor of {ha_api_drift.FLOORS['call_args']} — "
"the flow-display owners are not load-bearing"
)
assert any("call_args" in line for line in fewer.floor_violations()), (
fewer.floor_violations()
)
# --------------------------------------- t63: the media-browser root (real HA)
#
# The live report ("Browse media seems to spin and never finish loading") came
# from what async_browse_media HANDED BACK, not from the handler: our method
# returns with no awaits, so nothing inside it can hang. HA's handler only
# forwards the payload, which makes the defect visible on one path only:
#
# websocket_browse_media -> player.async_browse_media -> payload.as_dict()
#
# The frontend bundle is NOT installed here (no hass_frontend, and there is no
# browser to drive), so the rendered dialog itself cannot be exercised. What
# these tests prove is that backend path end to end, decorators included. The
# pre-fix payload carried ``media_content_id=None`` — a node shape no core
# integration ever serves, and the assertions below fail on it.
class _RecordingConnection:
"""The pieces of websocket_api.ActiveConnection the browse handler calls."""
def __init__(self):
self.results: list = []
self.messages: list = []
self.exceptions: list = []
def send_result(self, msg_id, result):
self.results.append((msg_id, result))
def send_message(self, message):
self.messages.append(message)
def async_handle_exception(self, msg, err):
self.exceptions.append(err)
def _wire_the_browser(entity):
"""Give the entity's own hass what websocket_browse_media walks.
``hass.data[DATA_COMPONENT].get_entity`` is how the handler finds the player,
and ``async_create_background_task`` is how ``@websocket_api.async_response``
schedules the real coroutine — both real shapes, so the DECORATED handler
runs unmodified.
"""
from homeassistant.components.media_player import DATA_COMPONENT
scheduled: list = []
def async_create_background_task(coro, name, *, eager_start=False):
task = asyncio.create_task(coro)
scheduled.append(task)
return task
entity.hass.data[DATA_COMPONENT] = SimpleNamespace(
get_entity=lambda entity_id: entity if entity_id == entity.entity_id else None
)
entity.hass.async_create_background_task = async_create_background_task
return scheduled
async def _browse_over_the_real_handler(entity, scheduled, *, content_id=None,
content_type=None):
"""Send the real websocket command and await the task it schedules."""
from homeassistant.components.media_player import websocket_browse_media
connection = _RecordingConnection()
msg = {"id": 7, "type": "media_player/browse_media", "entity_id": entity.entity_id}
if content_id is not None or content_type is not None:
msg["media_content_id"] = content_id
msg["media_content_type"] = content_type
websocket_browse_media(entity.hass, connection, msg) # schedules the coroutine
await asyncio.gather(*scheduled)
assert connection.exceptions == [], connection.exceptions
assert connection.messages == [], connection.messages
assert len(connection.results) == 1, connection.results
return connection.results[0][1]
async def test_the_real_websocket_handler_serves_a_string_id_root():
"""t63 regression: the browse root carries a real STRING id, not None.
Every core media_player platform serves a string id (a literal, a module
constant or ""), because the frontend keys the node it is loading on that id;
the live dialog re-requested the same node for ever with a null one.
"""
_require_installed_homeassistant()
entity, _states = _real_ha_entity(
"media_player", "NvidiaShieldMediaPlayer", entity_id="media_player.shield"
)
scheduled = _wire_the_browser(entity)
payload = await _browse_over_the_real_handler(entity, scheduled)
assert isinstance(payload["media_content_id"], str)
assert payload["media_content_id"] == "root"
assert payload["media_content_type"] == "directory" # not MediaType.APPS
assert payload["children"] == []
assert payload["can_expand"] is False
assert payload["can_play"] is False
assert payload["media_class"] == "directory"
# the payload the frontend receives has to survive the websocket's own encode
assert json.loads(json.dumps(payload)) == payload
async def test_the_real_handler_answers_every_id_with_the_same_root():
"""Idempotent: the root id, None and a stale child id give ONE payload.
That is the property that stops a frontend bouncing between "loading" and
"loaded" for one id — browse(root_id) must be browse(None) in shape.
"""
_require_installed_homeassistant()
entity, _states = _real_ha_entity(
"media_player", "NvidiaShieldMediaPlayer", entity_id="media_player.shield"
)
scheduled = _wire_the_browser(entity)
first = await _browse_over_the_real_handler(entity, scheduled)
root_id = first["media_content_id"]
for content_type, content_id in (
(None, None),
("apps", root_id),
("apps", "apps"),
("app", "ar.tvplayer.tv"),
):
repeat = await _browse_over_the_real_handler(
entity, scheduled, content_id=content_id, content_type=content_type
)
assert repeat == first
+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:]
+44 -15
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:
@@ -84,14 +79,20 @@ class _SeedHass(SimpleNamespace):
config_entries = _SeedEntriesStub() config_entries = _SeedEntriesStub()
config_entries.updating_entry_calls = [] config_entries.updating_entry_calls = []
self._services = {("nvidia_shield", "adb_command"): None} self._services = {("nvidia_shield", "adb_command"): None}
#: t14: per-service response support the registration declared.
self.response_support: dict[tuple, object] = {}
super().__init__( super().__init__(
config_entries=config_entries, config_entries=config_entries,
scheduled_tasks=[], scheduled_tasks=[],
services=self, services=self,
) )
def async_register(self, domain, service, func, schema=None): def async_register(
self, domain, service, func, schema=None, supports_response=None
):
self._services[(domain, service)] = (func, schema) self._services[(domain, service)] = (func, schema)
#: t14: HA is told whether the action answers with a response.
self.response_support[(domain, service)] = supports_response
def has_service(self, domain, service): def has_service(self, domain, service):
return (domain, service) in self._services return (domain, service) in self._services
@@ -119,8 +120,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 +130,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 +179,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 +258,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])
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+93
View File
@@ -125,6 +125,76 @@ class TestPlaybackStatePosition:
assert sessions[0].position is None assert sessions[0].position is None
class TestPlaybackStateBufferingT55:
"""t55: Android PlaybackState 6 is BUFFERING, mapped in the parser.
The Shield's own sessions report the same integer stock Android does (6),
so no vendor-specific variant is treated as buffering — a device variant
would be a template field on the same PlaybackState line, never a
different integer.
"""
def _buffering_dump(self, state=6, speed="0.0"):
return (
"Sessions Stack - have 1 sessions:\n"
" Some app session com.netflix.ninja/Session (userId=0)\n"
" package=com.netflix.ninja\n"
" active=true\n"
f" state=PlaybackState {{state={state}, position=0, "
f"buffered position=2000, speed={speed}, updated=900, actions=1, "
"custom actions=[], active item id=0, error=null}\n"
" metadata: size=2, description=Movie Title, Some Artist\n"
)
def test_state_6_is_buffering(self):
sessions = parse_all_media_sessions(self._buffering_dump())
assert len(sessions) == 1
session = sessions[0]
assert session.buffering is True
assert session.playing is False
assert session.paused is False
assert session.title == "Movie Title"
def test_state_3_and_2_stay_playing_and_paused(self):
for state, attr in ((3, "playing"), (2, "paused")):
sessions = parse_all_media_sessions(self._buffering_dump(state=state))
session = sessions[0]
assert session.buffering is False
assert getattr(session, attr) is True
def test_state_6_wins_when_the_dump_says_speed_zero_too(self):
"""The buffering signal is the integer, not the speed field."""
sessions = parse_all_media_sessions(self._buffering_dump(state=6))
assert sessions[0].buffering is True
def test_buffering_outranks_a_paused_session_in_the_pick_order(self):
sessions = parse_all_media_sessions(
self._buffering_dump()
+ " Other app session com.example.player/Session (userId=0)\n"
" package=com.example.player\n"
" active=true\n"
" state=PlaybackState {state=2, position=1, speed=0.0, "
"updated=1, actions=1, custom actions=[], active item id=0, "
"error=null}\n"
" metadata: size=1, description=Paused Tune\n"
)
assert [s.package for s in sessions] == [
"com.netflix.ninja",
"com.example.player",
]
# and parse_media_session (no foreground preference) picks buffering
dump = self._buffering_dump() + (
" Other app session com.example.player/Session (userId=0)\n"
" package=com.example.player\n"
" active=true\n"
" state=PlaybackState {state=2, position=1, speed=0.0, "
"updated=1, actions=1, custom actions=[], active item id=0, "
"error=null}\n"
" metadata: size=1, description=Paused Tune\n"
)
assert parse_media_session(dump).package == "com.netflix.ninja"
class TestAudioPlaybackSection: class TestAudioPlaybackSection:
"""The "Audio playback (lastly played comes first)" section lists apps """The "Audio playback (lastly played comes first)" section lists apps
that recently played audio even with no MediaSession (R2).""" that recently played audio even with no MediaSession (R2)."""
@@ -160,6 +230,29 @@ class TestAudioPlaybackSection:
assert parse_audio_playback("") == [] assert parse_audio_playback("") == []
def test_running_apps_buffering_state_t55():
"""t55: a buffering session (and a buffering last-known) is named buffering."""
from nsh.ha_adb_common import MediaSessionInfo, running_apps_from_tasks
tasks = [
{"package": "com.netflix.ninja"},
{"package": "ar.tvplayer.tv"},
]
sessions = [MediaSessionInfo(package="com.netflix.ninja", buffering=True)]
last_known = {
"ar.tvplayer.tv": MediaSessionInfo(package="ar.tvplayer.tv", buffering=True)
}
apps = running_apps_from_tasks(
tasks, sessions, foreground_package=None, last_known=last_known
)
netflix = next(a for a in apps if a["package"] == "com.netflix.ninja")
tivi = next(a for a in apps if a["package"] == "ar.tvplayer.tv")
assert netflix["state"] == "buffering"
assert tivi["last_known_state"] == "buffering"
def test_running_apps_last_known_enrichment(): def test_running_apps_last_known_enrichment():
"""P3-3b: apps without a live session carry their TTL-capped last-known state.""" """P3-3b: apps without a live session carry their TTL-capped last-known state."""
from nsh.ha_adb_common import MediaSessionInfo, running_apps_from_tasks from nsh.ha_adb_common import MediaSessionInfo, running_apps_from_tasks
+494
View File
@@ -0,0 +1,494 @@
"""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
# t66: the curated table grew to cover the apps the report listed, and the
# dropdown is the same table (so it is asserted against the table itself,
# not a copy that would drift)
from nsh.const import DEFAULT_APP_DIRECTORY
assert player.source_list == list(DEFAULT_APP_DIRECTORY.values())
assert "Netflix" in player.source_list # a name the user recognises
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
def test_the_list_is_the_normalized_directory_exactly(self, modules):
"""Same accessor, same order, same bytes — a blank entry excepted (t49).
t61 removed the browse tree that used to be the second consumer of this
one accessor; the dropdown and the reverse lookup are still fed from it,
so a row can only launch the app it shows.
"""
for app_map in (
None, # the curated default
{"ar.tvplayer.tv": "TiviMate", "com.netflix.ninja": "Netflix"},
):
player, _served = _player(modules, app_map=app_map)
assert player.source_list == list(player._app_directory.values())
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.cache_app_label("com.netflix.ninja", "Netflix")
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.
t61 left two surfaces instead of three: the Browse tree that used to be the
second view is gone (apps are sources), so the pins below anchor the same
policy on `_app_directory` — the shared accessor itself — and on
`source_list`/`_source_package`, which are what a user can still reach.
"""
BLANKED = {
"com.blank": "",
TIVIMATE: "TiviMate",
"com.spaces": " ",
"": "Ghost",
}
def test_a_blank_entry_never_reaches_the_dropdown(self, modules):
"""Blanks are dropped once, in the accessor every offer reads (t49)."""
player, _served = _player(modules, app_map=dict(self.BLANKED))
assert list(player._app_directory) == [TIVIMATE]
assert list(player._app_directory.values()) == ["TiviMate"]
assert player.source_list == ["TiviMate"]
def test_a_blank_package_is_dropped_too(self, modules):
"""An entry with no package is a row that cannot be launched."""
player, _served = _player(
modules, app_map={"": "Ghost", " ": "Ghost 2", TIVIMATE: "TiviMate"}
)
assert list(player._app_directory) == [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"},
)
# display and match key are one string, everywhere
assert player.source_list == ["TiviMate", "Netflix"]
assert list(player._app_directory.values()) == ["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 "}
)
# t61: the directory keeps BOTH entries, each under its own package id;
# the removal of the browse tree does not remove the reachability it
# used to expose — select_source-by-id is that surface now.
assert player._app_directory == {"com.a": "Netflix", "com.b": "Netflix"}
assert player._source_package("com.b") == "com.b" # the shadowed package
# 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"}
)
assert player._app_directory == {
TIVIMATE: "TiviMate Pro",
"com.netflix.ninja": "Netflix",
}
assert player.source_list == ["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 directory still holds both apps, each under its own id, so
# the shadowed one stays reachable by its package id (t61: that is what
# the removed browse tree used to publish)
assert player._app_directory == {"com.a": "Dup", "com.b": "dup"}
assert player._source_package("com.b") == "com.b"
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_the_chain(self, modules):
"""Blanks are not displayed as nothing — the chain continues past them (t66).
Past a blank map value comes the device label, then the curated table, then
the raw id: `ar.tvplayer.tv` is curated, so the dropdown row is its NAME.
The raw-id tail of the same chain is pinned in the test below.
"""
player, _served = _player(modules, app_map={TIVIMATE: ""}, foreground=TIVIMATE)
assert player.source == "TiviMate"
def test_a_package_nothing_names_stays_raw(self, modules):
"""The last tier: no map value, no device label, no curated row → the id.
It is shown ONCE (the id itself), never as a `pkg=pkg` pair.
"""
player, _served = _player(
modules, app_map={"com.example.unknown": ""}, foreground="com.example.unknown"
)
assert player.source == "com.example.unknown"
assert player.source_list == [] # a name is the ticker's raw id
+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 [])
+175
View File
@@ -0,0 +1,175 @@
"""tests/test_now_playing_publication.py — which entity states may carry a reading (t17/O2).
THE OBSERVATION THIS CLOSES. The batch review read `extra_state_attributes` and
argued the now-playing row publishes "the entity state verbatim", so a dashboard
could see `now_playing_state: on`, while the README documents only `playing`,
`paused`, `buffering` and `idle`. t10's author had reported the opposite (an
`on` or `off` device publishes no row at all). Both accounts were about a
different thing, and this file settles it BY EXECUTION:
* the t10 account is true for the NATURAL ladder — `on` means no source reported
a power or playback fact, and a reading only exists when a tier answered, so
the two cannot meet in practice;
* the review was right about the RULE, which was "publish unless the state is
`off`" — an unenforced assumption, and the probe below shows it publishing
`now_playing_state: on` (and `standby`) the moment a reading exists beside that
state. `standby` is not even in the README, and nothing in the code stopped it.
WHAT WAS CHANGED. The publication rule now names the four values a reading may
carry (`NOW_PLAYING_STATE_VALUES`) and drops the reading when the entity state is
anything else, exactly as a state of `off` always did. The README says the same
thing in words. The two tests here walk EVERY ``MediaPlayerState`` member and
compare the README against the code constant, so neither can drift again.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import entity_stublib # noqa: E402
import pytest # noqa: E402
from entity_stublib import MediaPlayerState, load_entity_modules # noqa: E402
@pytest.fixture(autouse=True)
def scoped_entity_stubs():
"""Container for the HA stubs + the loaded entity modules (t7 discipline)."""
snapshot = entity_stublib.snapshot_modules()
entity_stublib.install()
yield load_entity_modules()
entity_stublib.restore_modules(snapshot)
README = Path(__file__).resolve().parent.parent / "README.md"
#: The states the README documents, read from the file itself (never from the
#: constant under test) so a code change cannot silently rewrite the pin.
def _documented_states() -> set[str]:
text = README.read_text(encoding="utf-8")
row = re.search(r"^\| `now_playing_state` \|(.*)\|$", text, re.M)
assert row, "the README's now_playing_state table row is gone"
return {value for value in re.findall(r"`([a-z_]+)`", row.group(1)) if value != "on"}
def _player_for_state(modules, member):
"""A player whose ladder answers `member` while a reading is available.
The state is forced by patching the ``state`` property, which is the only way
to ask "what would be published IF the ladder ever answered this?" — the
question the review raised. A reading is genuine: the foreground tier answers
it through the same code path production uses (``_now_playing``).
"""
from test_command_routing import TIVIMATE_PACKAGE, _now_playing_attrs, _state_player
player, _manager = _state_player(modules, foreground=TIVIMATE_PACKAGE)
player._last_channel = "101"
media_player_cls = type(player)
original = media_player_cls.__dict__.get("state")
media_player_cls.state = property(lambda self, forced=member: forced)
try:
reading = player._now_playing()
attrs = _now_playing_attrs(player)
finally:
if original is None: # pragma: no cover - the class always declares it
del media_player_cls.state
else:
media_player_cls.state = original
return reading, attrs
class TestThePublicationRule:
"""One reading in, one row out — for EVERY member the enum has."""
def test_the_documented_four_publish_the_reading(self, scoped_entity_stubs):
modules = scoped_entity_stubs
documented = {
MediaPlayerState.PLAYING: "playing",
MediaPlayerState.PAUSED: "paused",
MediaPlayerState.BUFFERING: "buffering",
MediaPlayerState.IDLE: "idle",
}
for member, value in documented.items():
reading, attrs = _player_for_state(modules, member)
assert reading, f"{value}: the fixture must have a reading to publish"
assert attrs["now_playing_state"] == value
assert attrs["now_playing_title"] == "101"
def test_a_state_outside_the_documented_four_publishes_nothing(self, scoped_entity_stubs):
"""The review's case, executed: `on` publishes no row — by construction now."""
modules = scoped_entity_stubs
reading_available = {
MediaPlayerState.ON: "on",
MediaPlayerState.OFF: "off",
MediaPlayerState.STANDBY: "standby",
}
for member, value in reading_available.items():
reading, attrs = _player_for_state(modules, member)
assert reading, f"{value}: the fixture still HAS a reading (that is the point)"
assert attrs == {}, f"{value} must publish no now-playing row, got {attrs}"
def test_every_enum_member_is_accounted_for(self, scoped_entity_stubs):
"""The matrix is exhaustive: no state is left untested as the enum grows."""
modules = scoped_entity_stubs
published = {}
for member in MediaPlayerState:
reading, attrs = _player_for_state(modules, member)
published[member.value] = attrs.get("now_playing_state")
assert set(published) == {member.value for member in MediaPlayerState}
for value in ("playing", "paused", "buffering", "idle"):
assert published[value] == value
for value in ("on", "off", "standby"):
assert published[value] is None, f"{value} published a state: {published[value]}"
def test_the_gate_reads_the_named_constant(self, scoped_entity_stubs):
"""The rule is the constant, not a hard-coded pair of states."""
from nsh.media_player import NOW_PLAYING_STATE_VALUES
assert NOW_PLAYING_STATE_VALUES == ("playing", "paused", "buffering", "idle")
assert self._published_for(scoped_entity_stubs, MediaPlayerState.ON) == {}
@staticmethod
def _published_for(modules, member) -> dict:
_reading, attrs = _player_for_state(modules, member)
return attrs
class TestTheReadmeAgrees:
"""The docs and the code say the same four values (t17/O2's anti-drift pin)."""
def test_the_table_row_lists_exactly_the_constant(self, scoped_entity_stubs):
from nsh.media_player import NOW_PLAYING_STATE_VALUES
assert _documented_states() == set(NOW_PLAYING_STATE_VALUES)
def test_the_prose_names_the_same_set_and_excludes_on(self, scoped_entity_stubs):
text = README.read_text(encoding="utf-8")
from nsh.media_player import NOW_PLAYING_STATE_VALUES
paragraph = re.search(
r"\*\*`now_playing_state` is one of FOUR values[^*]*\*\*(.*?)\n-", text, re.S
)
assert paragraph, "the README's now_playing_state paragraph is gone"
body = paragraph.group(1)
# the sentence that names the set, read apart from the sentence that
# excludes `on`/`off` — otherwise the exclusion would look like a member
sentence = re.search(r"full set is ([^.]+)\.", body)
assert sentence, "the README no longer names the full set"
named = set(re.findall(r"`([a-z_]+)`", sentence.group(1)))
assert named == set(NOW_PLAYING_STATE_VALUES), named
assert "`on`" in body and "not one of them" in text, (
"the paragraph must say `on` is excluded"
)
def test_the_off_and_on_prose_still_says_no_rows(self, scoped_entity_stubs):
text = README.read_text(encoding="utf-8")
assert "**A device that reads `off` publishes none of them.**" in text
assert "publishes none either" in text
+747 -9
View File
@@ -1,33 +1,69 @@
"""Unit tests for the protocol transport (androidtvremote2, Phase 2, t8). """Unit tests for the protocol transport (androidtvremote2, Phase 2, t8).
Fake client mirrors AndroidTVRemote's surface (callback registration, Fake client mirrors AndroidTVRemote's surface (callback registration, an
``keep_reconnecting`` that owns its own reconnect task, sync ``disconnect``); ``async_connect`` that must run BEFORE ``keep_reconnecting``, a self-owned
reconnect task that is a no-op until a session exists, sync ``disconnect``);
no real gRPC / no Home Assistant. Also covers `volume_tuple` normalization no real gRPC / no Home Assistant. Also covers `volume_tuple` normalization
against the real library's VolumeInfo TypedDict when the package is present against the real library's VolumeInfo TypedDict when the package is present
(`uvx --with androidtvremote2 pytest`). (`uvx --with androidtvremote2 pytest`).
""" """
import asyncio import asyncio
import sys import sys
import time import time
from types import SimpleNamespace
import pytest import pytest
from nsh.connection import ( from nsh.connection import (
FIELD_AUDIO_PLAYBACK,
FIELD_AWAKE, FIELD_AWAKE,
FIELD_FOREGROUND,
FIELD_IS_AVAILABLE, FIELD_IS_AVAILABLE,
FIELD_IS_ON, FIELD_IS_ON,
FIELD_RUNNING_APPS,
FIELD_SESSIONS, FIELD_SESSIONS,
FIELD_VOLUME_INFO, FIELD_VOLUME_INFO,
PROTOCOL_CONNECTED,
ShieldConnectionManager, ShieldConnectionManager,
StateStore, StateStore,
volume_tuple, volume_tuple,
) )
from nsh.const import CONF_ADB_ENABLED
class InvalidAuth(Exception): # noqa: N818 - the library's own class name
"""The library's InvalidAuth, matched by NAME (the manager never imports it)."""
class CannotConnect(Exception): # noqa: N818 - the library's own class name
"""The library's CannotConnect — a retryable transport failure."""
class ConnectionClosed(Exception): # noqa: N818 - the library's own class name
"""The library's ConnectionClosed — raised when nothing is connected."""
class FakeProtocolClient: class FakeProtocolClient:
"""AndroidTVRemote-shaped fake: callbacks + self-owned reconnect task.""" """AndroidTVRemote-shaped fake: the library's ORDER, reproduced.
def __init__(self): t15: the real ``AndroidTVRemote`` keeps ``_remote_message_protocol = None`` until
``async_connect()`` succeeds, and ``keep_reconnecting()`` starts a loop whose
condition is ``while self._remote_message_protocol:`` — so calling it alone
starts a task that returns immediately and NOTHING ever connects (no push, and
every send raises ConnectionClosed). The fake reproduces that, and records which
of the two calls the manager made, so a regression back to keep_reconnecting-only
fails a test instead of silently disabling the transport.
"""
def __init__(self, connect_error: Exception | None = None):
self._remote_message_protocol = None # library attribute: None until connected
self._connect_error = connect_error
self.connect_calls = 0
self.keep_reconnecting_calls = 0
self.keys_sent: list = []
self.apps_launched: list = []
self.texts_sent: list = []
self._is_on_callbacks: list = [] self._is_on_callbacks: list = []
self._is_available_callbacks: list = [] self._is_available_callbacks: list = []
self._current_app_callbacks: list = [] self._current_app_callbacks: list = []
@@ -66,18 +102,50 @@ class FakeProtocolClient:
for cb in list(self._volume_callbacks): for cb in list(self._volume_callbacks):
cb(info) cb(info)
# -- lifecycle -- # -- lifecycle --
def keep_reconnecting(self, invalid_auth_callback=None): def keep_reconnecting(self, invalid_auth_callback=None):
"""Own task like the real library (0.1 s → 30 s backoff internally).""" """Own task like the real library — and, like it, a NO-OP until connected.
The condition is the library's own: with no session the task runs once,
finds nothing to maintain and returns. That is exactly why
``async_connect()`` has to come first.
"""
self.keep_reconnecting_calls += 1
self._invalid_auth_callback = invalid_auth_callback self._invalid_auth_callback = invalid_auth_callback
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
self._reconnect_task = loop.create_task(self._reconnect_loop()) self._reconnect_task = loop.create_task(self._reconnect_loop())
async def _reconnect_loop(self): async def _reconnect_loop(self):
while True: # the real task sleeps through its internal backoff while self._remote_message_protocol: # the library's own guard
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
# -- connect (t15: the call the manager used to be missing entirely) --
async def async_connect(self):
"""Connect; raise ``self._connect_error`` when the test wants a failure."""
self.connect_calls += 1
if self._connect_error is not None:
raise self._connect_error
self._remote_message_protocol = object() # library sets its RemoteProtocol
# -- sends (t15: the library raises when nothing is connected) --
def send_key_command(self, key, direction="SHORT"):
if not self._remote_message_protocol:
raise ConnectionClosed("Called send_key_command after disconnect")
self.keys_sent.append((key, direction))
def send_launch_app_command(self, app_link_or_app_id):
if not self._remote_message_protocol:
raise ConnectionClosed("Called send_launch_app_command after disconnect")
self.apps_launched.append(app_link_or_app_id)
def send_text(self, text):
if not self._remote_message_protocol:
raise ConnectionClosed("Called send_text after disconnect")
self.texts_sent.append(text)
def fire_invalid_auth(self): def fire_invalid_auth(self):
if self._invalid_auth_callback is not None:
self._invalid_auth_callback() self._invalid_auth_callback()
def disconnect(self): def disconnect(self):
@@ -86,8 +154,21 @@ class FakeProtocolClient:
self._reconnect_task.cancel() self._reconnect_task.cancel()
def _adb_entry():
"""A realistic entry: its own option says ADB is ON (t23/O1).
The protocol tests exercise the ADB watchdog paths too (`_watchdog_cycle`),
and a manager with no entry means ADB is off — the gate those paths now ask.
"""
return SimpleNamespace(
options={CONF_ADB_ENABLED: True}, data={}, title="Shield", entry_id="e1"
)
def _protocol_manager(client=None, **kwargs) -> ShieldConnectionManager: def _protocol_manager(client=None, **kwargs) -> ShieldConnectionManager:
return ShieldConnectionManager(hass=None, entry=None, protocol=client, **kwargs) return ShieldConnectionManager(
hass=None, entry=_adb_entry(), protocol=client, **kwargs
)
async def _drain_loop(): async def _drain_loop():
@@ -95,6 +176,16 @@ async def _drain_loop():
await asyncio.sleep(0.01) await asyncio.sleep(0.01)
async def _connect(manager, client, tries: int = 40):
"""Wait for the manager's background connect task to make its attempt (t15)."""
for _ in range(tries):
if client.connect_calls:
await _drain_loop()
return
await asyncio.sleep(0.005)
raise AssertionError("the manager never attempted to connect the protocol")
class TestPushCallbackWiring: class TestPushCallbackWiring:
async def test_is_on_and_availability_push_into_store(self): async def test_is_on_and_availability_push_into_store(self):
client = FakeProtocolClient() client = FakeProtocolClient()
@@ -142,6 +233,7 @@ class TestInvalidAuthHook:
client, invalid_auth_callback=lambda: fired.append(True) client, invalid_auth_callback=lambda: fired.append(True)
) )
await manager._start_protocol() await manager._start_protocol()
await _connect(manager, client) # t15: a session first, then pushes/auth
client.fire_invalid_auth() client.fire_invalid_auth()
@@ -152,10 +244,202 @@ class TestInvalidAuthHook:
client = FakeProtocolClient() client = FakeProtocolClient()
manager = _protocol_manager(client) manager = _protocol_manager(client)
await manager._start_protocol() await manager._start_protocol()
await _connect(manager, client)
client.fire_invalid_auth() # no callback registered — must not raise client.fire_invalid_auth() # no callback registered — must not raise
await manager.async_stop() await manager.async_stop()
def _fast_backoff(manager, delay: float = 0.01) -> None:
"""Make the connect-retry backoff fast for tests (t15), per manager."""
manager._jitter = lambda _delay: delay
class TestInitialConnect:
"""t15: the transport must CONNECT, not merely be told to keep reconnecting.
The shipped bug: `_start_protocol` called `keep_reconnecting()` and nothing in
the tree ever called `async_connect()`. The library's reconnect task is
`while self._remote_message_protocol:` — None until a first connect — so the
task returned immediately: no session, no push, every send raising
ConnectionClosed inside a swallowed handler, for every install. These tests pin
the order (connect first) and the state it reports.
"""
async def test_connects_before_handing_over_to_keep_reconnecting(self):
client = FakeProtocolClient()
manager = _protocol_manager(client)
await manager._start_protocol()
await _connect(manager, client)
assert client.connect_calls == 1
assert client.keep_reconnecting_calls == 1
assert client._remote_message_protocol is not None, "a session exists now"
assert client._reconnect_task is not None, (
"keep_reconnecting only maintains a session; a task with no session "
"would be the pre-t15 no-op"
)
assert manager.protocol_state == "connected"
assert manager.protocol_connected is True
assert manager.protocol_ever_authenticated is True
assert manager.protocol_last_error is None
await manager.async_stop()
async def test_keep_reconnecting_alone_would_stay_unconnected(self):
"""The fake reproduces the library: without async_connect nothing happens."""
client = FakeProtocolClient()
client.keep_reconnecting(lambda: None)
await _drain_loop()
assert client._reconnect_task.done(), (
"the library's loop returns at once with no session — which is why "
"keep_reconnecting() alone never connected anything"
)
assert client.connect_calls == 0
async def test_network_failure_keeps_retrying_and_stays_never_connected(self):
client = FakeProtocolClient(connect_error=CannotConnect("unreachable"))
manager = _protocol_manager(client)
_fast_backoff(manager)
await manager._start_protocol()
await _connect(manager, client)
assert manager.protocol_state == "never_connected"
assert manager.protocol_connected is False
assert "CannotConnect" in (manager.protocol_last_error or "")
assert client.keep_reconnecting_calls == 0, (
"nothing to maintain until a session exists"
)
for _ in range(200): # the retry loop must keep trying
if client.connect_calls >= 2:
break
await asyncio.sleep(0.005)
assert client.connect_calls >= 2, "a network failure is retried with backoff"
assert manager.protocol_ever_authenticated is False
await manager.async_stop()
async def test_auth_rejection_stops_and_runs_the_reauth_hook(self):
client = FakeProtocolClient(connect_error=InvalidAuth("Need to pair"))
fired = []
manager = _protocol_manager(
client, invalid_auth_callback=lambda: fired.append(True)
)
await manager._start_protocol()
await _connect(manager, client)
assert manager.protocol_state == "auth_rejected"
assert fired == [True], "the entry's reauth/pair flow is the answer"
assert "InvalidAuth" in (manager.protocol_last_error or "")
assert client.keep_reconnecting_calls == 0
connect_calls = client.connect_calls
await asyncio.sleep(0.05)
assert client.connect_calls == connect_calls, (
"retrying a rejected certificate can never succeed"
)
await manager.async_stop()
async def test_auth_rejection_after_a_session_marks_the_state(self):
client = FakeProtocolClient()
manager = _protocol_manager(client)
await manager._start_protocol()
await _connect(manager, client)
assert manager.protocol_state == "connected"
client.fire_invalid_auth()
assert manager.protocol_state == "auth_rejected"
assert manager.protocol_last_error # readable, not only logged
await manager.async_stop()
class TestHonestSendFailures:
"""t15: a send that could not run must never look like one that did."""
async def test_send_without_a_session_warns_and_records(self, caplog):
client = FakeProtocolClient()
manager = _protocol_manager(client)
await manager._start_protocol()
await _connect(manager, client)
client._remote_message_protocol = None # session lost (library no-ops sends)
with caplog.at_level("WARNING"):
sent = await manager.send_protocol_key("POWER")
assert sent is False
assert manager.protocol_failures == 1
assert "POWER" in (manager.protocol_last_error or "")
assert "ConnectionClosed" in (manager.protocol_last_error or "")
assert any(
"did not run" in rec.message for rec in caplog.records
), "the failure is a WARNING carrying what happened"
assert client.keys_sent == []
await manager.async_stop()
async def test_launch_without_a_transport_warns_and_records(self, caplog):
manager = _protocol_manager(None) # protocol disabled for this entry
with caplog.at_level("WARNING"):
sent = await manager.launch_app("com.netflix.ninja")
assert sent is False
assert manager.protocol_failures == 1
assert "no protocol transport" in (manager.protocol_last_error or "")
assert any("did not run" in rec.message for rec in caplog.records)
await manager.async_stop()
async def test_a_working_send_is_not_counted_as_a_failure(self):
client = FakeProtocolClient()
manager = _protocol_manager(client)
await manager._start_protocol()
await _connect(manager, client)
assert await manager.send_protocol_key("POWER") is True
assert await manager.launch_app("com.netflix.ninja") is True
assert client.keys_sent == [("POWER", "SHORT")]
assert client.apps_launched == ["com.netflix.ninja"]
assert manager.protocol_failures == 0
assert manager.protocol_last_error is None
await manager.async_stop()
async def test_availability_push_drives_the_connected_state(self):
client = FakeProtocolClient()
manager = _protocol_manager(client)
await manager._start_protocol()
await _connect(manager, client)
client.push_is_available(False)
await _drain_loop()
assert manager.protocol_state == "disconnected"
assert manager.protocol_connected is False
client.push_is_available(True)
await _drain_loop()
assert manager.protocol_state == "connected"
await manager.async_stop()
async def test_a_push_proves_an_authenticated_session(self):
"""The library exposes no is_paired: a push is the only proof there is."""
client = FakeProtocolClient(connect_error=CannotConnect("down"))
manager = _protocol_manager(client)
await manager._start_protocol()
await _connect(manager, client)
assert manager.protocol_ever_authenticated is False
client.push_current_app("com.netflix.ninja")
await _drain_loop()
assert manager.protocol_ever_authenticated is True
await manager.async_stop()
async def test_transport_state_is_off_without_a_client(self):
manager = _protocol_manager(None)
await manager._start_protocol()
assert manager.protocol_state == "off"
assert manager.protocol_connected is False
assert manager.protocol_ever_authenticated is False
await manager.async_stop()
class TestLifecycle: class TestLifecycle:
async def test_stop_disconnects_lib_owned_reconnect_task(self): async def test_stop_disconnects_lib_owned_reconnect_task(self):
"""The library owns keep_reconnecting's task; closing the client """The library owns keep_reconnecting's task; closing the client
@@ -163,6 +447,7 @@ class TestLifecycle:
client = FakeProtocolClient() client = FakeProtocolClient()
manager = _protocol_manager(client) manager = _protocol_manager(client)
await manager._start_protocol() await manager._start_protocol()
await _connect(manager, client)
task = client._reconnect_task task = client._reconnect_task
assert task is not None assert task is not None
@@ -184,7 +469,7 @@ class TestLifecycle:
monkeypatch.setattr(ShieldConnectionManager, "build_protocol_client", fake_builder) monkeypatch.setattr(ShieldConnectionManager, "build_protocol_client", fake_builder)
manager = ShieldConnectionManager( manager = ShieldConnectionManager(
hass=None, hass=None,
entry=None, entry=_adb_entry(),
protocol_settings={ protocol_settings={
"host": "shield.local", "host": "shield.local",
"client_name": "Home Assistant", "client_name": "Home Assistant",
@@ -291,7 +576,7 @@ class TestAdbOutageKeepsProtocolTruth:
poller.awake = None # wake poll fails → cycle failure path poller.awake = None # wake poll fails → cycle failure path
client = FakeProtocolClient() client = FakeProtocolClient()
manager = ShieldConnectionManager( manager = ShieldConnectionManager(
hass=None, entry=None, poller=poller, protocol=client hass=None, entry=_adb_entry(), poller=poller, protocol=client
) )
await manager._start_protocol() await manager._start_protocol()
@@ -315,6 +600,117 @@ class TestAdbOutageKeepsProtocolTruth:
await manager.async_stop() await manager.async_stop()
class TestAdbStaysThePrimarySource:
"""t15: arming the protocol must not promote its pushes over ADB's readings.
The pushes this unit turns ON (is_on / is_available / current_app / volume) had
NEVER been live here — ``keep_reconnecting`` alone never connected — so the whole
protocol surface is new traffic next to an ADB media-session poller the user
relies on. The store's fields are disjoint by design; these tests pin that, and
that an ADB-only entry keeps working with only the protocol surface degraded.
"""
@staticmethod
def _adbreath(manager):
"""Every field the ADB poller owns, stamped, and read back."""
manager.store.set(FIELD_AWAKE, True)
manager.store.set(FIELD_SESSIONS, ["session-data"])
manager.store.set(FIELD_AUDIO_PLAYBACK, ["com.audio.last"])
manager.store.set(FIELD_RUNNING_APPS, [{"package": "com.run.now"}])
manager.store.set(FIELD_FOREGROUND, "com.adb.foreground")
return {
field: manager.store.get(field)
for field in (
FIELD_AWAKE,
FIELD_SESSIONS,
FIELD_AUDIO_PLAYBACK,
FIELD_RUNNING_APPS,
FIELD_FOREGROUND,
)
}
async def test_a_protocol_push_never_overwrites_an_adb_reading(self):
client = FakeProtocolClient()
manager = _protocol_manager(client)
await manager._start_protocol()
await _connect(manager, client)
before = self._adbreath(manager)
client.push_is_on(True)
client.push_is_available(True)
client.push_current_app("com.protocol.foreground")
client.push_volume_info({"level": 5, "max": 15, "muted": False})
await _drain_loop()
assert {
field: manager.store.get(field) for field in before
} == before, "ADB's own fields are untouched by every protocol push"
# ...and the protocol wrote exactly its own four
assert manager.is_on is True
assert manager.is_available is True
assert manager.current_app == "com.protocol.foreground"
assert manager.volume_info == (5.0, 15.0, False)
await manager.async_stop()
async def test_the_foreground_identity_falls_back_to_adb(self):
"""No push yet: the ADB parse answers, and nothing is invented."""
client = FakeProtocolClient(connect_error=CannotConnect("down"))
manager = _protocol_manager(client)
await manager._start_protocol()
await _connect(manager, client)
self._adbreath(manager)
assert manager.current_app is None, "no protocol push ever arrived"
assert manager.foreground_app == "com.adb.foreground"
assert manager.foreground_source == "adb", "the tier is named, not implied"
await manager.async_stop()
async def test_a_push_wins_while_fresh_and_the_adb_reading_survives_it(self):
"""The documented priority, and no loss: an expired push re-exposes ADB."""
now = [1000.0]
store = StateStore(ttl=60.0, clock=lambda: now[0])
client = FakeProtocolClient()
manager = _protocol_manager(client, store=store)
await manager._start_protocol()
await _connect(manager, client)
self._adbreath(manager)
client.push_current_app("com.protocol.foreground")
await _drain_loop()
assert manager.foreground_app == "com.protocol.foreground"
assert manager.foreground_source == "protocol"
assert manager.store.get(FIELD_FOREGROUND) == "com.adb.foreground", (
"the push wrote its OWN field — the ADB reading was not clobbered"
)
now[0] += 60.1 # both fields expire on the store's own TTL, not by a push
assert manager.current_app is None
assert manager.store.get(FIELD_FOREGROUND) is None
# re-stamped (the next ADB poll), the ADB reading answers again untouched
manager.store.set(FIELD_FOREGROUND, "com.adb.foreground")
assert manager.foreground_app == "com.adb.foreground"
assert manager.foreground_source == "adb"
await manager.async_stop()
async def test_an_adb_only_entry_keeps_working(self):
"""No protocol client at all: ADB truth refreshes, only the transport degrades."""
poller = FakeAdbForOutage()
poller.connected = True
poller.awake = True
manager = ShieldConnectionManager(
hass=None, entry=_adb_entry(), poller=poller
)
await manager._start_protocol()
assert manager._protocol is None
assert manager.protocol_state == "off"
assert manager.protocol_connected is False
assert manager.protocol_ever_authenticated is False
await manager.async_stop()
class TestImportGuard: class TestImportGuard:
def test_missing_library_degrades_to_adb_only(self, monkeypatch, caplog): def test_missing_library_degrades_to_adb_only(self, monkeypatch, caplog):
"""F1: a protocol-enabled entry without androidtvremote2 keeps """F1: a protocol-enabled entry without androidtvremote2 keeps
@@ -362,3 +758,345 @@ class TestVolumeTuple:
androidtvremote2 = pytest.importorskip("androidtvremote2") androidtvremote2 = pytest.importorskip("androidtvremote2")
info = androidtvremote2.VolumeInfo(level=3, max=15, muted=True) info = androidtvremote2.VolumeInfo(level=3, max=15, muted=True)
assert volume_tuple(info) == (3.0, 15.0, True) assert volume_tuple(info) == (3.0, 15.0, True)
# ── t20: the t19 review's protocol findings ──
class HangingProtocolClient(FakeProtocolClient):
"""``async_connect()`` that never returns until the test says so (t20).
The lifecycle findings are about what happens WHILE an attempt is in flight,
so the fake has to be able to sit inside ``async_connect``: it parks on an
event, records how many times it was cancelled, and only then - if it is ever
released - becomes a session.
"""
def __init__(self):
super().__init__()
self.entered = asyncio.Event()
self.release = asyncio.Event()
self.cancelled = 0
async def async_connect(self):
self.connect_calls += 1
self.entered.set()
try:
await self.release.wait()
except asyncio.CancelledError:
self.cancelled += 1
raise
self._remote_message_protocol = object()
class OnceFailingProtocolClient(FakeProtocolClient):
"""Fails its first attempts, then connects - the RECOVERY sequence (t20/O1)."""
def __init__(self, failures: int = 1, error: Exception | None = None):
super().__init__()
self._failures_left = failures
self._error = error or CannotConnect("Net: boom")
async def async_connect(self):
self.connect_calls += 1
if self._failures_left > 0:
self._failures_left -= 1
raise self._error
self._remote_message_protocol = object()
class TestRecoveredErrorsAreNotStale:
"""t20/O1: the published pair can never contradict itself.
``protocol_state``/``protocol_connected`` is the transport's CURRENT
condition; ``protocol_last_error`` is the failure the CURRENT state has not
recovered from. It is cleared whenever a session becomes proven and kept while
the transport is still down (and, deliberately, kept for a failure that
happened AFTER the session came up - that one is current, not stale).
"""
async def test_failure_then_recovery_publishes_no_stale_error(self):
"""The measured defect: 'connect: Net: boom' beside connected: true."""
client = OnceFailingProtocolClient()
manager = _protocol_manager(client)
_fast_backoff(manager)
await manager._start_protocol()
for _ in range(200): # the failure is recorded while it is still down
if manager.protocol_last_error:
break
await asyncio.sleep(0.005)
assert manager.protocol_state == "never_connected"
assert manager.protocol_connected is False
assert "CannotConnect" in (manager.protocol_last_error or "")
for _ in range(400): # the retry succeeds
if manager.protocol_connected:
break
await asyncio.sleep(0.005)
assert manager.protocol_connected is True
assert manager.protocol_last_error is None, (
"a recovered failure must not be published beside connected: true"
)
assert manager.protocol_failures == 0, "a connect failure is not a SEND failure"
await manager.async_stop()
async def test_failure_then_still_disconnected_keeps_the_error(self):
"""The other direction: a failure that still describes the transport stays."""
client = FakeProtocolClient(connect_error=CannotConnect("Net: boom"))
manager = _protocol_manager(client)
manager._jitter = lambda delay: 60.0 # one attempt, then sleep past the test
await manager._start_protocol()
await _connect(manager, client)
assert manager.protocol_state == "never_connected"
assert manager.protocol_connected is False
assert "Net: boom" in (manager.protocol_last_error or "")
await manager.async_stop()
async def test_a_send_failure_before_a_session_is_cleared_by_the_connect(self):
"""The review's exact sequence, driven through public calls."""
client = FakeProtocolClient()
manager = _protocol_manager(client)
assert await manager.send_protocol_key("POWER") is False # no session yet
assert manager.protocol_last_error, "the current failure is recorded"
await manager._start_protocol()
await _connect(manager, client)
assert manager.protocol_connected is True
assert manager.protocol_last_error is None, "recovery clears it"
assert manager.protocol_failures == 1, "history stays in the counter"
await manager.async_stop()
async def test_an_availability_push_also_clears_a_recovered_error(self):
"""A push is the OTHER proof of a live session, so it clears it too."""
client = FakeProtocolClient(connect_error=CannotConnect("Net: boom"))
manager = _protocol_manager(client)
manager._jitter = lambda delay: 60.0 # the retry cannot race this assertion
await manager._start_protocol()
await _connect(manager, client)
assert manager.protocol_last_error
client.push_is_available(True)
await _drain_loop()
assert manager.protocol_state == "connected"
assert manager.protocol_last_error is None
await manager.async_stop()
async def test_a_send_failure_after_the_session_came_up_is_current(self):
"""Not the same thing as stale: the session failed just now."""
client = FakeProtocolClient()
manager = _protocol_manager(client)
await manager._start_protocol()
await _connect(manager, client)
assert manager.protocol_last_error is None
client._remote_message_protocol = None # the session is gone
assert await manager.send_protocol_key("POWER") is False
assert "POWER" in (manager.protocol_last_error or "")
# ...and a session proven again clears it
client._remote_message_protocol = object()
client.push_is_available(True)
await _drain_loop()
assert manager.protocol_last_error is None
await manager.async_stop()
async def test_recovery_notifies_listeners_because_a_published_value_changed(self):
"""The error is an attribute: clearing it is a state change to publish."""
manager = _protocol_manager(FakeProtocolClient())
notified = []
manager.register_listener(lambda: notified.append(manager.protocol_last_error))
await manager.send_protocol_key("POWER") # records an error
assert manager.protocol_last_error
before = len(notified)
manager._note_protocol_state(PROTOCOL_CONNECTED, None)
assert len(notified) > before, "listeners were told the error went away"
assert manager.protocol_last_error is None
class TestConnectAttemptLifecycle:
"""t20/O4 + O5: the connect attempt is tracked, and a second start is defined.
Both findings interact with the options UI: every save reloads the entry, so an
attempt that survives its entry (or a start that orphans an earlier one) can
accumulate reconnect loops across saves.
"""
async def test_unload_during_a_connect_attempt_cancels_and_awaits_it(self):
"""The proof for O4: no live task, and no further connect attempts."""
client = HangingProtocolClient()
manager = _protocol_manager(client)
_fast_backoff(manager)
await manager._start_protocol()
await asyncio.wait_for(client.entered.wait(), 1)
task = manager._protocol_connect_task
assert task is not None and not task.done()
assert task in manager._tasks, "tracked, so unload can cancel it (O4)"
assert client.connect_calls == 1
await manager.async_stop()
assert task.done(), "the unload AWAITED the attempt"
assert task.cancelled()
assert client.cancelled == 1, "the in-flight connect was cancelled"
assert manager._tasks == [], "nothing is left to leak into the next reload"
calls = client.connect_calls
for _ in range(20):
await asyncio.sleep(0.01)
assert client.connect_calls == calls, "no attempt survives the entry"
async def test_async_start_tracks_every_loop_and_stop_reaps_them(self):
client = HangingProtocolClient()
manager = _protocol_manager(client)
_fast_backoff(manager)
await manager.async_start() # the watchdog (passive here) + the connect
await asyncio.wait_for(client.entered.wait(), 1)
tracked = list(manager._tasks)
assert len(tracked) == 2, [t.get_name() for t in tracked]
await manager.async_stop()
assert manager._tasks == []
assert all(task.done() for task in tracked), "both were awaited"
async def test_a_second_start_reuses_the_live_attempt(self):
"""The proof for O5: starting twice leaves exactly ONE live attempt."""
client = HangingProtocolClient()
manager = _protocol_manager(client)
await manager._start_protocol()
await asyncio.wait_for(client.entered.wait(), 1)
first = manager._protocol_connect_task
await manager._start_protocol()
assert manager._protocol_connect_task is first, "reused, never orphaned"
assert client.connect_calls == 1, "exactly one attempt was made"
assert [t for t in manager._tasks if not t.done()] == [first]
assert manager._tasks.count(first) == 1, "registered once, not once per start"
assert client._is_on_callbacks == [manager._on_is_on], (
"the push callbacks are not registered twice either"
)
await manager.async_stop()
async def test_a_second_start_after_a_session_does_not_build_a_second_loop(self):
"""The library owns the session; a second keep_reconnecting would be a leak."""
client = FakeProtocolClient()
manager = _protocol_manager(client)
await manager._start_protocol()
await _connect(manager, client)
first = manager._protocol_connect_task
assert manager.protocol_connected is True
await manager._start_protocol()
assert manager._protocol_connect_task is first
assert client.connect_calls == 1
assert client.keep_reconnecting_calls == 1, "one owner for the session"
await manager.async_stop()
async def test_a_second_start_after_a_terminal_failure_tries_again(self):
"""Deterministic in BOTH directions: a finished attempt may be replaced."""
client = FakeProtocolClient(connect_error=InvalidAuth("Need to pair"))
manager = _protocol_manager(client, invalid_auth_callback=lambda: None)
await manager._start_protocol()
await _connect(manager, client)
first = manager._protocol_connect_task
assert first.done(), "the auth path is terminal: the loop returned"
assert manager.protocol_state == "auth_rejected"
await manager._start_protocol()
assert manager._protocol_connect_task is not first, "a FRESH attempt replaces it"
for _ in range(200): # the fresh task needs a turn to make its attempt
if client.connect_calls == 2:
break
await asyncio.sleep(0.005)
assert client.connect_calls == 2, "a restart really retries"
await manager.async_stop()
async def test_a_stopped_manager_is_not_restarted_by_a_late_call(self):
client = FakeProtocolClient()
manager = _protocol_manager(client)
await manager.async_stop()
await manager._start_protocol()
assert manager._protocol_connect_task is None
assert manager._tasks == []
class TestTransportBannersAreNotStale:
"""t20/O2: define stale, then make it impossible.
A STALE banner is one whose transport claim a later state change never followed
up on, so the log's last word contradicts the entity. The rules that remove it:
the affirmative banner states only what the attempt proved, and EVERY state
change announces itself with both the old and the new state.
"""
@staticmethod
def _state_lines(records):
return [r for r in records if "transport state:" in r.getMessage()]
async def test_the_connected_banner_claims_no_liveliness(self, caplog):
client = FakeProtocolClient()
manager = _protocol_manager(client)
with caplog.at_level("INFO"):
await manager._start_protocol()
await _connect(manager, client)
opened = [r for r in caplog.records if "session opened" in r.getMessage()]
assert opened, "connecting is announced"
text = opened[0].getMessage()
assert "what this proves" in text, "it names its own limit"
assert "availability push" in text, "and who maintains the session from there"
assert "paired" not in text.lower(), "a connect attempt cannot prove pairing"
lines = self._state_lines(caplog.records)
assert lines, "the transition is announced"
assert lines[-1].getMessage().endswith(manager.protocol_state)
await manager.async_stop()
async def test_a_later_state_change_announces_itself(self, caplog):
client = FakeProtocolClient()
manager = _protocol_manager(client)
await manager._start_protocol()
await _connect(manager, client)
with caplog.at_level("INFO"):
client.push_is_available(False) # the device reports the session down
await _drain_loop()
lines = self._state_lines(caplog.records)
assert lines, "a session going down is announced"
assert lines[-1].getMessage().endswith("disconnected")
assert manager.protocol_state == "disconnected", "the banner matches the entity"
await manager.async_stop()
async def test_the_open_banner_and_the_transition_agree(self, caplog):
"""No two lines about the same moment may name different states."""
client = FakeProtocolClient()
manager = _protocol_manager(client)
with caplog.at_level("INFO"):
await manager._start_protocol()
await _connect(manager, client)
opened = [r for r in caplog.records if "session opened" in r.getMessage()]
lines = self._state_lines(caplog.records)
assert opened and lines
assert "connected" in opened[0].getMessage()
assert lines[-1].getMessage().endswith("connected")
await manager.async_stop()
+143
View File
@@ -0,0 +1,143 @@
"""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
def test_the_readme_states_the_on_rule_precisely(readme_text):
"""t58 UNIT 0: "no data" was too loose; the README must not still say it."""
assert "no source reported a power or playback fact" in readme_text
# the four reporting-but-not-playing shapes, named
for shape in ("`idle`", "`standby`", "`unavailable`", "foreground app"):
assert shape in readme_text, shape
assert "no source reported anything at all" not in readme_text
# ── t17: the intents documentation ──
#: The keys a seeded intent row may carry (the seed validator in config_flow.py
#: decides what it ACCEPTS; these are the names the README may use).
INTENT_ROW_KEYS = {
"id",
"name",
"package",
"activity",
"action",
"categories",
"data",
"fields",
}
#: The keys one declared field may carry.
INTENT_FIELD_KEYS = {"key", "type", "required", "default"}
def _intents_example(text: str) -> str:
"""The `registered_intents:` block of the README's seed example (t17)."""
match = re.search(
r"```yaml\n(?:.*\n)*? registered_intents:\n((?:.*\n)*?)```", text
)
assert match, "the README has no `registered_intents:` example block"
return match.group(1)
def test_the_intents_example_only_uses_keys_the_seed_honours(readme_text):
"""Documented-but-ignored is the failure this pins (t30's rule, nested)."""
block = _intents_example(readme_text)
row_keys = set(re.findall(r"^\s*([a-z_]+):", block, re.M))
field_keys = set(re.findall(r"[{,]\s*([a-z_]+):", block))
assert row_keys, "the intents example declares no row keys"
assert row_keys <= INTENT_ROW_KEYS, sorted(row_keys - INTENT_ROW_KEYS)
assert field_keys <= INTENT_ROW_KEYS | INTENT_FIELD_KEYS, sorted(
field_keys - INTENT_ROW_KEYS - INTENT_FIELD_KEYS
)
def test_the_intents_screen_and_action_are_documented(readme_text):
"""The screen, its fields, the button and the action are all named (t17)."""
for needle in (
"### Intents (buttons and the `send_intent` action)",
"Configure → **Intents**",
"Variables (declared fields)",
"One button per registered intent",
"nvidia_shield.send_intent",
"intent_response",
):
assert needle in readme_text, needle
def test_the_intents_copy_states_the_honest_failures(readme_text):
"""The README names each way this can go wrong — and that it RAISES (t17)."""
for key in (
"unknown_intent",
"missing_intent_field",
"unknown_intent_variable",
"invalid_intent_field",
"intent_failed",
"intents_need_adb",
):
assert key in readme_text, key
assert "configuration problem" in readme_text
# the sentence wraps across lines in the source, so compare it flattened
flat = " ".join(readme_text.split())
assert "no `eval`, no `format()`, no shell interpolation" in flat
+180
View File
@@ -0,0 +1,180 @@
"""tests/test_receiver_switch.py — a plain switch as the Receiver (t61).
The user's request, verbatim: "it would be nice if a receiver could just be a
switch, I don't need my volume scripts then because the receiver portion could
just make sure the receiver is on if it's a switch". So the playback entry
points — ``turn_on``, ``play_media`` and ``select_source`` — ensure a
``switch.*`` receiver is ON, and only when it is actually off.
The four states the contract names are all pinned here:
* switch off + playback starts → exactly one ``switch.turn_on``;
* switch already on → NO call (idempotent, not a toggle);
* switch unavailable/unknown/missing/failing → playback continues, nothing raises;
* a ``media_player.*`` receiver → never switched; its behaviour is unchanged.
No real Shield, no real switch and no browser take part: the entity comes from
the same stub harness every other entity test uses, the receiver's state is a
value in ``FakeStates`` and the service call is recorded.
"""
import entity_stublib
import pytest
from nsh.const import CONF_APP_NAME_MAP, CONF_RECEIVER_ENTITY
from test_command_routing import make_player
TIVIMATE = "ar.tvplayer.tv"
SOUNDBAR = "switch.soundbar"
AVR = "media_player.avr"
@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, *, receiver=SOUNDBAR, state=None, app_map=None):
"""A player with a Receiver configured (a switch unless told otherwise)."""
player, served, _intents = make_player(modules)
player._entry.options[CONF_RECEIVER_ENTITY] = receiver
if app_map is not None:
player._entry.options[CONF_APP_NAME_MAP] = app_map
if state is not None:
player.hass.states.set(receiver, state)
return player, served
class TestTheSwitchReceiverIsEnsuredOn:
"""Every playback entry point turns an off switch receiver on — once."""
async def test_play_media_turns_an_off_receiver_on(self, modules):
player, served = _player(modules, state="off")
await player.async_play_media("app", TIVIMATE)
# the ensure is the ONLY service call: no volume, no toggle, and the
# app still launches (which is why the volume scripts are unnecessary)
assert served == [("switch", "turn_on", {"entity_id": SOUNDBAR})]
assert player._manager._protocol.launch_calls == [TIVIMATE]
async def test_turn_on_turns_an_off_receiver_on(self, modules):
player, served = _player(modules, state="off")
await player.async_turn_on()
assert served == [("switch", "turn_on", {"entity_id": SOUNDBAR})]
async def test_select_source_turns_an_off_receiver_on_once(self, modules):
"""select_source dispatches internally, so the switch is read once."""
player, served = _player(
modules, state="off", app_map={TIVIMATE: "TiviMate"}
)
await player.async_select_source("TiviMate")
assert served == [("switch", "turn_on", {"entity_id": SOUNDBAR})]
assert player._manager._protocol.launch_calls == [TIVIMATE]
async def test_an_on_receiver_is_never_called(self, modules):
"""Idempotent: every entry point may run, and NOT ONE call goes out."""
player, served = _player(modules, state="on")
await player.async_turn_on()
await player.async_play_media("app", TIVIMATE)
await player.async_select_source("TiviMate")
assert served == []
async def test_no_volume_service_is_ever_aimed_at_the_receiver(self, modules):
"""The user's point: a switch receiver means no volume scripts."""
player, served = _player(modules, state="off")
await player.async_play_media("app", TIVIMATE)
assert [(domain, service) for domain, service, _data in served] == [
("switch", "turn_on")
]
class TestAnUnusableReceiverNeverBreaksPlayback:
"""A receiver we cannot read, or that fails, is logged — never raised."""
async def test_an_unavailable_switch_is_left_alone(self, modules):
player, served = _player(modules, state="unavailable")
await player.async_play_media("app", TIVIMATE)
assert served == [] # never guessed at
assert player._manager._protocol.launch_calls == [TIVIMATE] # still plays
async def test_an_unknown_state_is_not_guessed_at(self, modules):
player, served = _player(modules, state="unknown")
await player.async_turn_on()
assert served == []
async def test_a_missing_entity_is_left_alone(self, modules):
player, served = _player(modules) # the receiver is not in the machine
await player.async_play_media("app", TIVIMATE)
assert served == []
assert player._manager._protocol.launch_calls == [TIVIMATE]
async def test_a_failing_service_call_does_not_stop_playback(self, modules):
from homeassistant.exceptions import HomeAssistantError
player, _served = _player(modules, state="off")
class _BrokenServices:
async def async_call(self, domain, service, data=None, **kwargs):
raise HomeAssistantError("the switch entity is gone")
player.hass.services = _BrokenServices()
await player.async_play_media("app", TIVIMATE) # must not raise
assert player._manager._protocol.launch_calls == [TIVIMATE]
class TestAMediaPlayerReceiverIsUntouched:
"""An AVR stays an AVR: input selection and enrichment, never a switch call."""
async def test_a_media_player_receiver_is_never_switched(self, modules):
player, served = _player(modules, receiver=AVR, state="off")
player.hass.states.set(AVR, "idle")
await player.async_turn_on()
await player.async_play_media("app", TIVIMATE)
assert served == []
assert player._manager._protocol.launch_calls == [TIVIMATE]
async def test_the_receiver_option_still_reaches_the_attributes(self, modules):
from nsh.const import ATTR_MEDIA_DAG_RECEIVER_ENTITY
player, _served = _player(modules, receiver=AVR)
assert (
player.extra_state_attributes[ATTR_MEDIA_DAG_RECEIVER_ENTITY] == AVR
)
class TestAnIdleShieldLeavesTheReceiverAlone:
"""Switching the receiver on is a PLAYBACK action, not a synchronisation."""
async def test_reading_state_and_attributes_calls_nothing(self, modules):
player, served = _player(modules, state="off")
assert player.state is not None # a state read
assert isinstance(player.extra_state_attributes, dict) # what the frontend reads
player._on_manager_update() # the connection manager pushed an update
assert served == []
+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() == ""
+383
View File
@@ -0,0 +1,383 @@
"""t66: the opt-in screen-capture IMAGE entity.
WHAT IS PROVEN HERE, AND WHAT IS NOT
------------------------------------
Proven: the bytes that reach Home Assistant are REAL PNG bytes — ``PNG_B64``
below is a real 2x1 PNG, base64'd on the wire exactly the way
``screencap -p | base64`` sends it — the entity hands HA ``bytes`` and never a
``str``, a disconnected link or a bad answer makes the entity UNAVAILABLE
instead of serving a blank/zero-byte image, one capture is coalesced and reused
so a dashboard cannot hammer the device, and the option defaults OFF so no
entity exists until the user opts in.
NOT proven: anything about a real Shield. No device was available in this
environment, so ``screencap``'s flags, the device's base64 and — the important
one — how the persistent ADB socket copes with a few hundred kilobytes of
base64 in a single ``shell()`` call are INFERRED, not measured. ``FakeAdbPoller``
answers the way a working Shield should.
"""
import asyncio
import base64
import json
import sys
from datetime import datetime
from types import SimpleNamespace
import entity_stublib
import pytest
from conftest import _SHIELD_DIR
from entity_stublib import install, load_entity_modules
#: A REAL 2x1 PNG (one red pixel, one green pixel) — 72 bytes, base64. It is the
#: wire form the device sends, so the fixture exercises the real decode path
#: rather than an invented one.
PNG_B64 = (
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAIAAAB7QOjdAAAAD0lEQVR42mP4z8DA8J8BAAf/"
"Af+4BDXgAAAAAElFTkSuQmCC"
)
PNG_BYTES = base64.b64decode(PNG_B64)
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
def wrapped(text: str, width: int = 76) -> str:
"""``base64``'s own line wrapping — the device wraps, and that is not corruption."""
return "\n".join(text[i : i + width] for i in range(0, len(text), width)) + "\n"
@pytest.fixture(autouse=True)
def scoped_entity_stubs():
"""Container for the HA stubs + loaded entity modules (t7 discipline)."""
snapshot = entity_stublib.snapshot_modules()
install()
yield load_entity_modules()
entity_stublib.restore_modules(snapshot)
@pytest.fixture(name="modules")
def load_modules_fixture():
return load_entity_modules()
class FakeAdbPoller:
"""The ADB seam the entity is allowed to use: ``connected`` + ``shell``."""
def __init__(self, answer=None, *, connected=True, delay=0.0):
self.connected = connected
self.answer = answer
self.delay = delay
self.calls: list[str] = []
async def shell(self, cmd: str):
self.calls.append(cmd)
if self.delay:
await asyncio.sleep(self.delay)
if isinstance(self.answer, Exception):
raise self.answer
if callable(self.answer):
return self.answer(cmd)
return self.answer
class StrictManager:
"""Only ``adb_poller`` may be touched — nothing else exists on purpose.
"Never a state source" is a claim about what the capture path reads and
writes: it reads the ADB poller for a screen grab and writes nothing at all.
A manager that raises on every other attribute turns a future reach-in (a
store write, a state push) into a failing test instead of a quiet coupling
between screen capture and the state pipeline.
"""
def __init__(self, poller):
self.adb_poller = poller
def __getattr__(self, name):
raise AssertionError(f"the screen-capture path touched manager.{name}")
def make_entry(options=None, *, entry_id="e1", title="Shield"):
return SimpleNamespace(
entry_id=entry_id, title=title, options=dict(options or {}), runtime_data=None
)
def make_entity(modules, poller, options=None):
"""The entity over a fake manager on the entry, plus the entry it belongs to."""
entry = make_entry(options or {"screen_capture_enabled": True})
if poller is not None:
entry.runtime_data = StrictManager(poller)
entity = modules["image"].NvidiaShieldScreenImage(SimpleNamespace(), entry)
return entity, entry
def fake_clock(modules, monkeypatch, start=1000.0):
"""Freeze ``time.monotonic`` for the capture-window tests. Returns the cell."""
now = [start]
monkeypatch.setattr(
modules["image"], "time", SimpleNamespace(monotonic=lambda: now[0])
)
return now
# ── the decode path ──────────────────────────────────────────────────────────
class TestScreenCaptureDecode:
"""``decode_screen_capture`` — the one place an image could be corrupted."""
def test_a_real_png_survives_the_wire_format(self, modules):
decode = modules["image"].decode_screen_capture
data = decode(wrapped(PNG_B64))
assert data == PNG_BYTES # byte-for-byte, wrapping and all
assert data.startswith(PNG_SIGNATURE)
assert isinstance(data, bytes) # not a str: this is base64-decoded
@pytest.mark.parametrize(
"answer",
[
None, # the transport never answered (AdbClientBase.shell's failure)
"", # an empty answer is not an image
"not base64 at all !!",
base64.b64encode(b"hello world, definitely not a png").decode(),
# a signature and nothing else: not a PNG (the IHDR chunk is missing)
base64.b64encode(PNG_SIGNATURE + b"\x00" * 8).decode(),
],
)
def test_everything_that_is_not_a_png_is_rejected(self, modules, answer):
assert modules["image"].decode_screen_capture(answer) is None
def test_a_device_error_line_is_not_an_image(self, modules):
polluted = "error: no display\n" + wrapped(PNG_B64)
assert modules["image"].decode_screen_capture(polluted) is None
# ── the entity ───────────────────────────────────────────────────────────────
class TestScreenCaptureEntity:
async def test_the_entity_serves_real_png_bytes_and_never_a_str(self, modules):
poller = FakeAdbPoller(wrapped(PNG_B64))
entity, _entry = make_entity(modules, poller)
data = await entity.async_image()
assert data == PNG_BYTES
assert data.startswith(PNG_SIGNATURE)
assert not isinstance(data, str)
assert entity.available is True
assert entity.capture_status == "ok"
assert entity.extra_state_attributes["capture_status"] == "ok"
async def test_the_command_is_the_base64_route_and_needs_no_device_file(self, modules):
"""Route 2 of the module docstring, and the reason it was chosen."""
poller = FakeAdbPoller(wrapped(PNG_B64))
entity, _entry = make_entity(modules, poller)
await entity.async_image()
assert poller.calls == ["screencap -p | base64"]
# No device-side artifact exists, so there is nothing to clean up...
assert "/sdcard" not in poller.calls[0]
assert "rm " not in poller.calls[0]
async def test_a_disconnected_link_is_unavailable_and_is_never_asked(self, modules):
poller = FakeAdbPoller(wrapped(PNG_B64), connected=False)
entity, _entry = make_entity(modules, poller)
assert entity.available is False
assert entity.capture_status == "adb_disconnected"
assert await entity.async_image() is None
assert poller.calls == [] # a dead link is not poked for a screenshot
async def test_adb_disabled_has_its_own_reason(self, modules):
entry = make_entry({"screen_capture_enabled": True})
manager = SimpleNamespace(adb_poller=None)
entry.runtime_data = manager
entity = modules["image"].NvidiaShieldScreenImage(SimpleNamespace(), entry)
assert entity.available is False
assert entity.capture_status == "adb_disabled"
assert await entity.async_image() is None
async def test_an_empty_answer_is_unavailable_not_a_blank_image(self, modules):
poller = FakeAdbPoller(None)
entity, _entry = make_entity(modules, poller)
assert await entity.async_image() is None
assert entity.available is False
assert entity.capture_status == "capture_failed"
async def test_a_transport_that_raises_is_a_failure_not_a_crash(self, modules):
poller = FakeAdbPoller(RuntimeError("socket closed under us"))
entity, _entry = make_entity(modules, poller)
assert await entity.async_image() is None
assert entity.available is False
assert entity.capture_status == "capture_failed"
async def test_a_failed_capture_recovers_on_the_next_request(self, modules):
poller = FakeAdbPoller(None)
entity, _entry = make_entity(modules, poller)
assert await entity.async_image() is None
assert entity.available is False
poller.answer = wrapped(PNG_B64) # the device came back
assert await entity.async_image() == PNG_BYTES
assert entity.available is True
assert entity.capture_status == "ok"
async def test_one_capture_is_reused_inside_the_window(self, modules, monkeypatch):
"""A dashboard repaint is not a screen grab per repaint."""
now = fake_clock(modules, monkeypatch)
poller = FakeAdbPoller(wrapped(PNG_B64))
entity, _entry = make_entity(modules, poller)
first = await entity.async_image()
now[0] += modules["image"].CAPTURE_REUSE_SECONDS - 1.0
second = await entity.async_image()
assert first == second == PNG_BYTES
assert len(poller.calls) == 1 # one shell, two image requests
async def test_the_window_expires_so_a_later_view_refreshes(self, modules, monkeypatch):
now = fake_clock(modules, monkeypatch)
poller = FakeAdbPoller(wrapped(PNG_B64))
entity, _entry = make_entity(modules, poller)
await entity.async_image()
now[0] += modules["image"].CAPTURE_REUSE_SECONDS + 1.0
await entity.async_image()
assert len(poller.calls) == 2
async def test_an_explicit_refresh_skips_the_reuse_window(self, modules, monkeypatch):
"""``homeassistant.update_entity`` means "grab it now"."""
fake_clock(modules, monkeypatch)
poller = FakeAdbPoller(wrapped(PNG_B64))
entity, _entry = make_entity(modules, poller)
await entity.async_image()
await entity.async_update()
assert len(poller.calls) == 2
async def test_concurrent_requests_share_one_capture(self, modules):
"""Parallel image fetches must not leak ADB shells."""
poller = FakeAdbPoller(wrapped(PNG_B64), delay=0.01)
entity, _entry = make_entity(modules, poller)
results = await asyncio.gather(
entity.async_image(), entity.async_image(), entity.async_image()
)
assert list(results) == [PNG_BYTES, PNG_BYTES, PNG_BYTES]
assert len(poller.calls) == 1
async def test_capture_reads_the_adb_poller_and_drives_nothing(self, modules):
"""The strict manager raises if the capture path touches anything else."""
poller = FakeAdbPoller(wrapped(PNG_B64))
entity, _entry = make_entity(modules, poller) # StrictManager
assert await entity.async_image() == PNG_BYTES
# ...and the state HA would record is the timestamp, never the bytes
assert entity.image_last_updated is not None
assert isinstance(entity.image_last_updated, datetime)
assert entity.image_last_updated.tzinfo is not None
async def test_image_last_updated_is_stamped_only_by_a_success(self, modules, monkeypatch):
fake_clock(modules, monkeypatch)
poller = FakeAdbPoller(None)
entity, _entry = make_entity(modules, poller)
assert entity.image_last_updated is None # nothing captured yet
assert await entity.async_image() is None
assert entity.image_last_updated is None # a failure stamps nothing
poller.answer = wrapped(PNG_B64)
await entity.async_image()
stamped = entity.image_last_updated
assert stamped is not None
poller.answer = None # a later failure must not move it
await entity.async_image()
assert entity.image_last_updated is stamped
def test_entity_hygiene(self, modules):
"""Naming, content type, recorder hygiene, and the no-poll rule."""
poller = FakeAdbPoller(wrapped(PNG_B64))
entity, entry = make_entity(modules, poller)
cls = type(entity)
assert cls._attr_has_entity_name is True
assert entity.translation_key == "screen_capture"
assert entity.content_type == "image/png"
# HA polls image entities never by default; this one must not either
assert cls._attr_should_poll is False
assert entity.should_poll is False
assert entity.unique_id == f"nvidia_shield_{entry.entry_id}_screen_capture"
assert entity.device_info["identifiers"] == {("nvidia_shield", entry.entry_id)}
# the status changes with a capture, so it stays out of the database, and
# no attribute ever carries the image bytes
assert "capture_status" in cls._unrecorded_attributes
for value in entity.extra_state_attributes.values():
assert not isinstance(value, (bytes, bytearray))
def test_the_platform_is_forwarded(self, modules):
"""The new platform is in the entry's list, and the manifest still parses."""
platforms = [str(p) for p in sys.modules["nsh.init"].PLATFORMS]
assert "image" in platforms
assert platforms.count("image") == 1
manifest = json.loads(
(_SHIELD_DIR / "manifest.json").read_text(encoding="utf-8")
)
# HA discovers platforms from the forwarded list; a "platforms" key in the
# manifest is not a thing (and adding one would be a silent no-op).
assert "platforms" not in manifest
assert "adb-shell>=0.4.0" in manifest["requirements"]
# ── opt-in ───────────────────────────────────────────────────────────────────
class TestScreenCaptureOptIn:
"""Default OFF: no option, no entity, no cost."""
async def _setup(self, modules, options):
added: list = []
entry = make_entry(options)
entry.runtime_data = StrictManager(FakeAdbPoller(wrapped(PNG_B64)))
await modules["image"].async_setup_entry(SimpleNamespace(), entry, added.extend)
return added
async def test_the_option_defaults_off_and_creates_no_entity(self, modules):
assert await self._setup(modules, {}) == []
assert sys.modules["nsh.const"].CONF_SCREEN_CAPTURE_ENABLED == (
"screen_capture_enabled"
)
assert await self._setup(modules, {"screen_capture_enabled": False}) == []
async def test_switching_it_on_creates_exactly_one_entity(self, modules):
added = await self._setup(modules, {"screen_capture_enabled": True})
assert len(added) == 1
assert type(added[0]).__name__ == "NvidiaShieldScreenImage"
async def test_an_entry_without_a_manager_is_skipped_not_crashed(self, modules):
added: list = []
entry = make_entry({"screen_capture_enabled": True})
entry.runtime_data = None
await modules["image"].async_setup_entry(SimpleNamespace(), entry, added.extend)
assert added == []
+478
View File
@@ -0,0 +1,478 @@
"""t17: `nvidia_shield.send_intent` against a REAL Home Assistant install.
The intents feature has one claim a stub cannot settle: that the action RETURNS
the device's output, and that the frontend is offered the registered intents as a
dropdown. Both are decided by Home Assistant itself — the response flag comes from
``async_set_service_schema`` reading ``hass.services.supports_response()``, and the
dropdown is whatever description the websocket command serves — so this file
drives the installed HA (2026.10.1) end to end:
* the REAL service registry: the registration ``async_setup`` makes, its schema,
and ``SupportsResponse.OPTIONAL`` (a *required* response would break every
caller that does not ask for one);
* the REAL ``get_services`` payload — the exact JSON the frontend parses — so the
intent dropdown and the entity target picker are proven where they are consumed;
* the REAL ``hass.services.async_call`` with ``return_response=True``: the output
comes back under ``intent_response``, and the command that reached the fake ADB
surface is asserted verbatim;
* the REAL exception machinery: an unknown intent, a refused intent and ADB being
down each raise ``ServiceValidationError`` with our translation key and a usable
message (never a silent success, never an empty response).
WHAT THIS FILE CANNOT PROVE: there is no Shield and no browser here. The command
string is asserted against the fake poller (the real ADB transport is t13/t14's
subject), and "how the dropdown LOOKS" is the frontend's business — the wire
contract is what this file pins. Every test skips cleanly when homeassistant is
not installed (run it with ``--with 'homeassistant>=2026.10' --with adb-shell``).
"""
from __future__ import annotations
import importlib
import json
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
import pytest_asyncio
pytest.importorskip(
"homeassistant",
reason="the real service registry is the point of this file",
)
from homeassistant.components.websocket_api import commands as ws_commands # noqa: E402
from homeassistant.config_entries import ( # noqa: E402
SOURCE_USER,
ConfigEntries,
ConfigEntry,
)
from homeassistant.core import HomeAssistant # noqa: E402
from homeassistant.exceptions import ServiceValidationError # noqa: E402
from homeassistant.helpers import device_registry as device_registry # noqa: E402
from homeassistant.helpers import entity_registry as er # noqa: E402
from homeassistant.helpers.entity_registry import ( # noqa: E402
EntityRegistryItems,
EntityRegistrySettings,
)
from homeassistant.helpers.service import ( # noqa: E402
async_get_all_descriptions,
)
_ROOT = Path(__file__).resolve().parent.parent
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
DOMAIN = "nvidia_shield"
SERVICE = "send_intent"
ENTITY_ID = "media_player.shield"
#: The registration this file sends: one component, one action, one declared
#: variable used by the data template, and one integer with a default.
NETFLIX_INTENT = {
"id": "netflix_title",
"name": "Netflix: open a title",
"package": "com.netflix.ninja",
"activity": "com.netflix.ninja/.MainActivity",
"action": "android.intent.action.VIEW",
"categories": ["android.intent.category.DEFAULT"],
"data": "https://www.netflix.com/watch/{id}",
"fields": [
{"key": "id", "type": "string", "required": True},
{"key": "resume", "type": "bool", "default": "true"},
],
}
#: What the handler must build for ``{"id": "80100172"}`` — asserted verbatim, so
#: a change to the flag order or the quoting is a test failure, not a surprise.
EXPECTED_COMMAND = (
"am start -n com.netflix.ninja/.MainActivity "
"-a android.intent.action.VIEW -c android.intent.category.DEFAULT "
"-d https://www.netflix.com/watch/80100172 "
"--es id 80100172 --ez resume true"
)
#: What a real `am start` prints when the device ACCEPTED the intent.
DEVICE_ANSWER = (
"Starting: Intent { act=android.intent.action.VIEW "
"cmp=com.netflix.ninja/.MainActivity }"
)
class FakePoller:
"""The ADB surface the handler uses: one shell call, real values back."""
def __init__(self, output=DEVICE_ANSWER, error=None):
self.output = output
self.error = error
self.calls: list[str] = []
async def shell(self, command):
self.calls.append(command)
if self.error is not None:
raise self.error
return self.output
@pytest_asyncio.fixture(name="ha")
async def ha_fixture(tmp_path):
"""A real HomeAssistant with the real registries and the real service registry.
Same shape as the adb_command file (t14): no core bootstrap, but the two
containers core's loader fills are constructed the very same way, so an entity
target expansion sees the entities the real one would.
"""
hass = HomeAssistant(str(tmp_path))
device_registry.async_setup(hass)
await device_registry.async_load(hass)
registry = er.async_get(hass)
registry.entities = EntityRegistryItems(hass)
registry.deleted_entities = {}
registry.settings = EntityRegistrySettings()
registry._entities_data = registry.entities.data
hass.data[er.DATA_REGISTRY] = registry
hass.config_entries = ConfigEntries(hass, {})
hass.config_entries.async_schedule_reload = lambda entry_id: None
entry = ConfigEntry(
version=1,
minor_version=1,
domain=DOMAIN,
title="Shield",
data={},
options={},
source=SOURCE_USER,
unique_id=None,
subentries_data=(),
discovery_keys={},
)
object.__setattr__(entry, "_options", {})
hass.config_entries._entries[entry.entry_id] = entry
hass._shield_entry = entry
return hass
@pytest.fixture(name="integration")
def integration_fixture():
"""The integration package, imported by path under its real name."""
return importlib.import_module("custom_components.nvidia_shield")
@pytest.fixture(name="media_player_module")
def media_player_fixture():
return importlib.import_module("custom_components.nvidia_shield.media_player")
@pytest.fixture(name="const")
def const_fixture():
return importlib.import_module("custom_components.nvidia_shield.const")
@pytest_asyncio.fixture(name="shield")
async def shield_fixture(ha, integration, media_player_module, const):
"""Set the integration up with ONE Shield carrying ONE registered intent.
The manager is a SimpleNamespace with the ENTRY on it, because the handler
reads the intents from the ENTRY's options — the same place the options flow
writes them, which is what makes this a real end-to-end path.
"""
await integration.async_setup(ha, {})
poller = FakePoller()
entry = SimpleNamespace(options={const.CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]})
manager = SimpleNamespace(adb_poller=poller, connected=True, entry=entry)
media_player_module._INTENT_TARGETS[ENTITY_ID] = manager
# the domain store a real entry setup fills, then the SAME call
# async_setup_entry makes — so the dropdown is published exactly as in
# production (and, when nothing is stored, comes out empty)
ha.data.setdefault(DOMAIN, {})["shield_entry"] = manager
integration._publish_send_intent_description(ha)
try:
yield poller
finally:
media_player_module._INTENT_TARGETS.pop(ENTITY_ID, None)
ha.data.get(DOMAIN, {}).pop("shield_entry", None)
def _description(descriptions):
return descriptions[DOMAIN][SERVICE]
class RecordingConnection:
"""The websocket connection shape ``handle_get_services`` needs."""
def __init__(self):
self.messages: list[dict] = []
def send_message(self, message):
self.messages.append(message)
def send_result(self, msg_id, payload):
self.messages.append({"id": msg_id, "type": "result", "result": payload})
async def test_the_registration_declares_an_optional_response(ha, integration, shield):
"""A response-bearing action must say so — OPTIONAL keeps callers free.
REQUIRED would make every existing automation that does not ask for a response
fail, so this is a compatibility gate, not a nicety.
"""
from homeassistant.core import SupportsResponse
service = ha.services.async_services()[DOMAIN][SERVICE]
assert service.supports_response is SupportsResponse.OPTIONAL
async def test_the_intent_field_is_a_dropdown_of_the_registered_names(
ha, integration, shield
):
"""THE dropdown: the frontend gets the registered intents as select options.
The options are published from the integration's own runtime description
(``async_set_service_schema``), because ``async_register`` has no field
metadata parameter — the same mechanism t14 proved for the target picker.
"""
description = _description(await async_get_all_descriptions(ha))
intent_field = description["fields"]["intent"]
assert intent_field["required"] is True
assert intent_field["selector"]["select"]["options"] == [NETFLIX_INTENT["name"]]
assert intent_field["selector"]["select"]["custom_value"] is True
# the target picker, and the response flag the UI reads
assert description["target"] == {"entity": [{"domain": ["media_player"]}]}
assert description["response"] == {"optional": True}
assert set(description["fields"]) == {"intent", "variables", "entity_id"}
# ...and the whole payload is JSON, which is what crosses the wire
assert json.loads(json.dumps(description))["fields"]["intent"]["selector"]
async def test_a_publish_with_nothing_stored_offers_no_options(ha, integration):
"""The honest empty state: no registrations means an EMPTY dropdown.
The action still accepts a name (the selector keeps ``custom_value``), so the
screen is not a dead end — it just does not invent options.
"""
await integration.async_setup(ha, {})
ha.data.setdefault(DOMAIN, {}).clear()
integration._publish_send_intent_description(ha)
description = _description(await async_get_all_descriptions(ha))
assert description["fields"]["intent"]["selector"]["select"]["options"] == []
async def test_the_websocket_command_serves_the_same_dropdown(
ha, integration, shield
):
"""The exact command the frontend calls, not the accessor behind it."""
connection = RecordingConnection()
ws_commands.handle_get_services(ha, connection, {"id": 11, "type": "get_services"})
await ha.async_block_till_done()
assert len(connection.messages) == 1
message = json.loads(connection.messages[0])
assert message["id"] == 11
served = message["result"][DOMAIN][SERVICE]
assert served["fields"]["intent"]["selector"]["select"]["options"] == [
NETFLIX_INTENT["name"]
]
assert served["response"] == {"optional": True}
async def test_a_call_returns_the_device_s_own_output(ha, integration, shield):
"""The whole point: the Shield's answer comes back to the caller."""
response = await ha.services.async_call(
DOMAIN,
SERVICE,
{
"intent": NETFLIX_INTENT["name"],
"variables": {"id": "80100172"},
"entity_id": ENTITY_ID,
},
blocking=True,
return_response=True,
)
assert response == {"intent_response": DEVICE_ANSWER}
assert shield.calls == [EXPECTED_COMMAND]
async def test_a_caller_that_asks_for_no_response_still_sends(ha, integration, shield):
"""A template that ignores the answer must not change the behaviour."""
response = await ha.services.async_call(
DOMAIN,
SERVICE,
{
"intent": NETFLIX_INTENT["name"],
"variables": {"id": "80100172"},
"entity_id": ENTITY_ID,
},
blocking=True,
return_response=False,
)
assert response is None
assert shield.calls == [EXPECTED_COMMAND]
async def test_the_variables_are_typed_onto_the_command(ha, integration, shield):
"""A declared int/bool reaches the device as its own flag, not as text."""
await ha.services.async_call(
DOMAIN,
SERVICE,
{
"intent": NETFLIX_INTENT["name"],
"variables": {"id": "80100172", "resume": "no"},
"entity_id": ENTITY_ID,
},
blocking=True,
return_response=True,
)
assert shield.calls == [
EXPECTED_COMMAND.replace("--ez resume true", "--ez resume false")
]
async def test_a_required_field_left_out_names_the_field(ha, integration, shield):
"""The action refuses BEFORE it sends: nothing travels half-specified."""
with pytest.raises(ServiceValidationError) as caught:
await ha.services.async_call(
DOMAIN,
SERVICE,
{"intent": NETFLIX_INTENT["name"], "entity_id": ENTITY_ID},
blocking=True,
return_response=True,
)
assert caught.value.translation_key == "missing_intent_field"
assert caught.value.translation_placeholders["field"] == "id"
assert "id" in str(caught.value)
assert shield.calls == [], "nothing was sent"
async def test_an_unknown_intent_lists_the_registered_ones(ha, integration, shield):
with pytest.raises(ServiceValidationError) as caught:
await ha.services.async_call(
DOMAIN,
SERVICE,
{"intent": "TiviMate: channel", "entity_id": ENTITY_ID},
blocking=True,
return_response=True,
)
assert caught.value.translation_key == "unknown_intent"
assert NETFLIX_INTENT["name"] in str(caught.value)
assert shield.calls == []
async def test_a_refused_intent_carries_the_device_s_message(ha, integration, shield):
"""The uninstalled-package case: the device's words, and an ERROR, not "ok"."""
shield.output = (
"Starting: Intent { act=android.intent.action.VIEW }\n"
"Error: Activity class {com.netflix.ninja/.MainActivity} does not exist."
)
with pytest.raises(ServiceValidationError) as caught:
await ha.services.async_call(
DOMAIN,
SERVICE,
{
"intent": NETFLIX_INTENT["name"],
"variables": {"id": "1"},
"entity_id": ENTITY_ID,
},
blocking=True,
return_response=True,
)
assert caught.value.translation_key == "intent_failed"
assert "does not exist" in str(caught.value)
assert len(shield.calls) == 1, "it was attempted exactly once, not retried"
async def test_adb_down_says_the_protocol_cannot_carry_an_intent(
ha, integration, media_player_module, const
):
"""The honest answer when the transport is the problem — not an empty reply."""
await integration.async_setup(ha, {})
poller = FakePoller()
entry = SimpleNamespace(options={const.CONF_REGISTERED_INTENTS: [NETFLIX_INTENT]})
media_player_module._INTENT_TARGETS[ENTITY_ID] = SimpleNamespace(
adb_poller=poller, connected=False, entry=entry
)
try:
with pytest.raises(ServiceValidationError) as caught:
await ha.services.async_call(
DOMAIN,
SERVICE,
{
"intent": NETFLIX_INTENT["name"],
"variables": {"id": "1"},
"entity_id": ENTITY_ID,
},
blocking=True,
return_response=True,
)
finally:
media_player_module._INTENT_TARGETS.pop(ENTITY_ID, None)
assert caught.value.translation_key == "intents_need_adb"
assert "protocol" in str(caught.value)
assert poller.calls == []
async def test_the_target_is_required_and_never_guessed(ha, integration, shield):
"""No target, and an entity that does not participate — two errors, named."""
with pytest.raises(ServiceValidationError) as caught:
await ha.services.async_call(
DOMAIN,
SERVICE,
{"intent": NETFLIX_INTENT["name"], "variables": {"id": "1"}},
blocking=True,
return_response=True,
)
assert caught.value.translation_key == "no_target"
assert SERVICE in str(caught.value), "the error names the action to fix"
with pytest.raises(ServiceValidationError) as caught:
await ha.services.async_call(
DOMAIN,
SERVICE,
{
"intent": NETFLIX_INTENT["name"],
"variables": {"id": "1"},
"entity_id": "media_player.other",
},
blocking=True,
return_response=True,
)
assert caught.value.translation_key == "unsupported_target"
assert shield.calls == []
async def test_the_strings_cover_every_error_this_file_provokes():
"""The translation keys the action raises exist in BOTH strings files.
A key without copy reaches the user as the raw key, so this is checked where
the real exception is raised rather than only in the unit suite.
"""
shield_dir = _ROOT / "custom_components" / "nvidia_shield"
for name in ("strings.json", "translations/en.json"):
data = json.loads((shield_dir / name).read_text(encoding="utf-8"))
for key in (
"unknown_intent",
"missing_intent_field",
"unknown_intent_variable",
"invalid_intent_field",
"intent_failed",
"intents_need_adb",
"no_target",
"unsupported_target",
"ambiguous_target",
):
assert key in data["exceptions"], (name, key)
for field in ("intent", "variables", "entity_id"):
assert field in data["services"][SERVICE]["fields"], (name, field)
File diff suppressed because it is too large. Load diff