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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No files matched your search

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

After

Width:  |  Height:  |  Size: 62 KiB

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