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
29 changed files with 6194 additions and 523 deletions

No files matched your search

+109 -4
View File
@@ -7,10 +7,11 @@
# firmware captures live in tests/fixtures
# (how to capture more: docs/RESEARCH_SHIELD.md §8).
#
# The `ha-api-drift` job is the ONE place Home Assistant is installed: every
# other job validates our code against hand-written stubs, which stay green
# even when a call site no longer matches the real API. That is how three
# drifts reached master (t36 D1/D2/D3) — see tests/ha_api_drift.py.
# 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
@@ -112,3 +113,107 @@ jobs:
- 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
+177 -23
View File
@@ -63,22 +63,45 @@ remote protocol is the fast push/command path:
1. **Plug off** — the power switch says `off` → `off`.
2. **Protocol power push** (`is_on`) — `False` → `off`; `None` falls through.
3. **ADB wake** (exact `mWakefulness` parse) — asleep → `idle` (standby).
4. **Intent** — a just-sent command's target state, self-healing against
3. **Intent** — a just-sent command's target state, self-healing against
reality within 30 s (retry goes over the transport that served it).
5. **Jellyfin** — `playing` / `paused` while a Jellyfin session is active.
6. **ADB media session** — `playing` / `paused` for apps that publish a
session (foreground app preferred).
7. **Foreground fallback** — an app is open (`current_app` protocol push,
then the ADB `mResumedActivity` parse) but publishes no media session →
`on`. Its identity comes from the TTL-capped last-known-state cache, the
last channel entered, or the zero-cost "audio playback" hint
(`media recently active`) — never resurrecting `playing` past the 60 s
state TTL.
8. **On** — powered, awake, nothing more specific.
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 6–7 plus
no MediaSession on Shield Android 11 (RESEARCH_SHIELD R2), so tiers 5–6 plus
the audio hint are what keep it visible.
**Standalone setup** offers both state sources at once — the Nvidia Remote
@@ -174,8 +197,8 @@ What each retired piece used to provide, and what covers it now:
| Retired piece | Covered by |
|---------------|------------|
| `androidtv` playback state | the ADB media-session state ladder (`playing` / `paused` / `idle` / `off`) |
| `androidtv` app name / `app_id` lookups | the manager's store + the app-name map + the manifest label of the running app |
| `androidtv` 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 |
@@ -281,8 +304,66 @@ helper the flow calls is a coroutine and its dedup only runs when awaited.
### Options
The Configure page is a **menu of sub-screens**, not one long form. Home Assistant
renders it as a list of rows; each row opens a short form and comes back to the menu,
and only **Save and close** commits:
| Row | What it holds |
| --- | --- |
| **Main setup** | Shield address, ADB port, `Read state over ADB`, `Add the Nvidia Remote Protocol`, `Show title and artist`, `Allow the adb_command action`, 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
- **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
@@ -358,12 +439,86 @@ A script that fails surfaces as a translated error on the service call that trig
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 the Browse tree and
app-name resolution use. With no map configured it falls back to a small built-in list
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:
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
@@ -377,7 +532,7 @@ entity: media_player.living_room_shield
sensor: your map first, then the cached device label, then the raw package.
- **`media_player.select_source`** — picks one **by name** and launches it over the
protocol, the same path `play_media` uses for apps. A raw package id works too (that
is what the Browse tree publishes), names match case-insensitively, and a name that
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
@@ -398,10 +553,9 @@ The map is your own override, so two shapes are accepted rather than papered ove
- **Names differing only by case** (`com.a=Dup`, `com.b=dup`) offer **one** dropdown
row — the first spelling — because name matching is case-insensitive. The shadowed
app is not lost: the Browse tree still lists it under its own package id, and
`media_player.select_source` accepts that id.
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 and the Browse tree, but `app_name`, `source` and the Current App sensor
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.
+695 -159
View File
@@ -130,6 +130,84 @@ def _app_map_from_text(
return app_map
def _apps_table_text(
stored_map: dict, stored_icons: dict, discovered: list[dict]
) -> str:
"""The Editable apps table: stored rows first, then discovered apps (t57).
Every app the Shield reports gets a row, so the page opens on the REAL app
list instead of the four curated entries ("it doesn't find all the apps").
The stored rows keep their position, name and icon — the user's edit is
never overwritten by a discovery — and a discovered app the map does not
mention is appended with the device label (or its package id when the label
fetch did not answer) and its heuristic icon.
The icon column is emitted only for a stored icon, because the discovered
icon is an MDI NAME (``mdi:netflix``) and the icon column's documented
values are a picture URL or a ``camera.*`` entity that feeds
``entity_picture``. Writing an ``mdi:`` string there would put a value the
media player cannot use into the user's own map — the icon is offered in the
table's description instead, and in the installed-apps sensor's attributes.
"""
lines: list[str] = []
seen: set[str] = set()
for package, name in (stored_map or {}).items():
seen.add(package)
icon = (stored_icons or {}).get(package)
lines.append(f"{package}={name}|{icon}" if icon else f"{package}={name}")
for app in discovered or []:
package = app.get("package")
if not package or package in seen:
continue
seen.add(package)
name = app.get("name") or package
lines.append(f"{package}={name}")
return "\n".join(lines)
def _options_field_values(options: dict | None) -> dict:
"""Entry options as FRONTEND-SERIALIZABLE field values (t56).
The bug this exists to kill: the apps map is stored as a DICT, and the
options page handed that dict straight to ``add_suggested_values_to_schema``
as the suggested value of a TEXT field. Real Home Assistant serializes the
suggested value verbatim into the form the frontend receives (verified on a
real install: ``{"suggested_value": {"ar.tvplayer.tv": "TiviMate"}}`` for a
``type: string`` field), and the browser renders a non-string as
``[object Object]``.
So the suggestion layer gets only what a text field can display: the apps
map (plus its icon column) serialized to the documented
``package=name[|icon]`` lines, and every other option value unchanged.
"""
options = dict(options or {})
app_map = options.pop(CONF_APP_NAME_MAP, None)
icon_map = options.pop(CONF_APP_ICON_MAP, None)
if isinstance(app_map, dict):
options[CONF_APP_NAME_MAP] = _app_map_to_text(
app_map, icon_map if isinstance(icon_map, dict) else None
)
return options
def _app_map_submission(value) -> tuple[dict, dict] | None:
"""Parse the apps-table field into ``(app_map, icon_map)``; None if invalid.
A blank field is the documented way to clear the map; anything else must
parse as ``package=name[|icon]`` lines or the whole submission is rejected
(never half-stored).
"""
if value is None:
return {}, {}
if not str(value).strip():
return {}, {}
icons: dict[str, str] = {}
app_map = _app_map_from_text(str(value), icons)
if app_map is None:
return None
return app_map, icons
def _host_field_key(field: str, host: str):
"""The host-field KEY, carrying the known address as its default.
@@ -208,8 +286,13 @@ def _build_selectors():
"media_player": _ClearingEntitySelector(
selector.EntitySelectorConfig(domain="media_player")
),
# t56: a receiver is a media_player (an AVR/soundbar) OR a plain switch
# (a soundbar/speaker that only knows on and off). The device_class
# constraint belongs to the media_player half only, so a switch is not
# filtered out by it — and the switch behaviour itself (turning it on
# during playback) lives in the media_player task that follows.
"receiver": _ClearingEntitySelector(
selector.EntitySelectorConfig(domain="media_player", device_class="receiver")
selector.EntitySelectorConfig(domain=["media_player", "switch"])
),
"power": _ClearingEntitySelector(
selector.EntitySelectorConfig(domain=["switch", "binary_sensor"])
@@ -289,14 +372,11 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
CONF_PROTOCOL_ENABLED: user_input.get(CONF_PROTOCOL_ENABLED, True),
}
# If ADB is enabled, go to pairing step
if self._user_data[CONF_ADB_ENABLED]:
return await self.async_step_adb_pair()
if self._user_data[CONF_PROTOCOL_ENABLED]:
return await self.async_step_protocol_pair()
return self._create_entry()
# t56: the first form stays exactly one screen (so nothing it used
# to accept moves or disappears), and the flow then becomes a MENU
# of sub-screens — Connection / Receiver / the pairing steps /
# Finish — instead of a single long page or a hidden sequence.
return await self.async_step_menu()
return self._show_user_form(errors)
@@ -404,15 +484,19 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return await self.async_step_user()
def _create_entry(self):
"""Create the config entry from stored user data.
"""Create the config entry from stored user data + setup sub-screens.
Everything the integration needs lives in the OPTIONS (t41: one host
field among them); ``data`` stays empty because no value has to be
immutable.
immutable. The setup sub-screens (t56) contribute ``_setup_extras`` —
disjoint keys merged over the first form, so a value configured on a
sub-screen wins over the same key left blank on the first form.
"""
d = self._user_data
d = {**self._user_data, **self._setup_extras}
return self.async_create_entry(
title=d["name"],
# the name is the entry title; a flow driven programmatically (and
# the first form always carries it) may omit it, so fall back
title=d.get("name") or DEFAULT_NAME,
data={},
options={
CONF_JELLYFIN_ENTITY: d.get(CONF_JELLYFIN_ENTITY, ""),
@@ -428,11 +512,18 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
)
async def _async_after_pairing(self):
"""Route after the pairing steps: protocol next, else create the entry."""
if self._user_data.get(CONF_PROTOCOL_ENABLED):
return await self.async_step_protocol_pair()
return self._create_entry()
"""Route after a pairing step: the protocol next, else the setup menu (t56).
The "ADB paired, now the protocol" sequence is deliberate and stays
automatic — a user who asked for both transports should not have to find
the second one in a menu — and the setup menu afterwards is where the
remaining sub-screens (Connection / Receiver) and Finish live.
"""
if self._user_data.get(CONF_PROTOCOL_ENABLED) and not getattr(
self, "_paired_protocol", False
):
return await self.async_step_protocol_pair()
return self._show_setup_menu()
def _discard_protocol_client(self) -> None:
"""Drop a pairing client whose session became void (host corrected)."""
@@ -607,7 +698,11 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return self._finish_reauth()
if self._reconfigure_entry is not None:
return self._finish_reconfigure(mac)
return self._create_entry()
# t56: the protocol is paired now, so the setup menu must not offer to
# pair it again — it returns to the menu (Receiver / Connection /
# Finish) instead of creating the entry here.
self._paired_protocol = True
return self._show_setup_menu()
async def async_step_import(self, data):
"""Seed an interactive flow from YAML (t30 U1, seed-only ruling).
@@ -765,6 +860,192 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
or ""
).strip()
# ── t56: the setup menu and its sub-screens ──
@property
def _setup_extras(self) -> dict:
"""Options collected on the setup sub-screens (t56), merged, not last-writer-wins.
Lazily created so the flow's ``__init__`` stays untouched; the menu's
sub-screens each own a disjoint set of keys and merge into this dict, so
configuring the receiver and then the connection (in either order)
cannot drop the other's edits.
"""
if getattr(self, "_setup_extra_options", None) is None:
self._setup_extra_options = {}
return self._setup_extra_options
def _show_setup_menu(self):
"""The setup menu: receiver, connection, pairing, finish (t56).
Home Assistant's standard menu step: it renders every option as a row and
dispatches the choice to ``async_step_<row id>``.
TWO rules this menu has to obey, both learned the hard way:
* the row id must BE its handler name — ``receiver``,
``connection``, ``adb_pair``, ``protocol_pair``, ``finish`` — because
that is literally how HA dispatches (``_raise_if_step_does_not_exist``
on ``async_step_<row id>``). A friendlier alias like ``adb`` is an
UnknownStep.
* a sub-screen's rendered ``step_id`` must be the SAME string, because a
submitted form is dispatched by its own step_id. Rendering
``setup_receiver`` from the ``receiver`` row makes the form
unsubmittable.
``async_show_menu`` takes no ``errors`` (verified against HA 2026.10.1:
``step_id``/``menu_options``/``sort``/``description_placeholders``
only), which is why this method takes none either.
Both transports stay optional (the pairing steps keep their skip), so
the user can finish at any point. A transport that already paired is not
offered again — that row would re-open a pairing session the Shield no
longer needs (file an entry option to change it).
"""
# built in one explicit order rather than a chain of insert(0), which
# is how this read backwards until real HA showed the row order
options = ["receiver", "connection"]
if self._user_data.get(CONF_ADB_ENABLED) and not getattr(
self, "_paired_adb", False
):
options.append("adb_pair")
if self._user_data.get(CONF_PROTOCOL_ENABLED) and not getattr(
self, "_paired_protocol", False
):
options.append("protocol_pair")
options.append("finish")
return self.async_show_menu(
step_id="menu",
menu_options=options,
description_placeholders=self._setup_menu_placeholders(),
)
def _setup_menu_placeholders(self) -> dict:
"""What the setup menu says about the transports it still offers."""
pending = []
if self._user_data.get(CONF_ADB_ENABLED) and not getattr(
self, "_paired_adb", False
):
pending.append("ADB")
if self._user_data.get(CONF_PROTOCOL_ENABLED) and not getattr(
self, "_paired_protocol", False
):
pending.append("the remote protocol")
if not pending:
return {"pairing": "everything that needs pairing is paired"}
return {"pairing": "still to pair: " + " and ".join(pending)}
async def async_step_menu(self, user_input=None):
"""First visit: open the pending pairing; later visits: show the menu.
Deliberately NOT the menu's ``finish`` row (that dispatches to
``async_step_finish``, a different method). The menu's own option ids
are therefore ``['connection', 'receiver', 'adb'?, 'protocol'?,
'finish']`` and never include ``menu`` itself, so the flow manager can
never bounce back into this method — the one shape that would loop.
``user_input`` is never a menu choice here either: the flow manager
reads ``next_step_id`` off the MENU result and calls the chosen step
directly, then that step comes back here to re-render.
"""
if not getattr(self, "_pairing_underway", False):
self._pairing_underway = True
if self._user_data.get(CONF_ADB_ENABLED):
return await self.async_step_adb_pair()
if self._user_data.get(CONF_PROTOCOL_ENABLED):
return await self.async_step_protocol_pair()
return self._show_setup_menu()
def _finish_setup(self):
"""Create the entry — the ``finish`` row and the no-transport path."""
return self._create_entry()
def _apply_setup_submission(self, user_input: dict) -> None:
"""Merge a sub-screen submission into the flow's pending options (t56).
ONE rule for every field, so "cleared" and "untouched" can never be
confused: a key the submission CARRIES is authoritative — an empty
value clears the setting — and a key it does NOT carry is untouched and
keeps its stored value. The frontend submits every rendered field, so a
missing key only ever means "that field was not on this screen".
"""
for key, value in user_input.items():
if value is None:
# an explicit null means the same as an empty value: clear
self._setup_extras[key] = ""
else:
self._setup_extras[key] = value
async def async_step_receiver(self, user_input=None):
"""Setup sub-screen: the receiver/soundbar this Shield plays through.
Accepts a media_player OR a plain switch (t56 string/selector change) —
the switch behaviour itself lands with the media_player work that
follows.
"""
if user_input is not None:
self._apply_setup_submission(user_input)
return await self.async_step_menu()
sel = _build_selectors()
receiver = self._setup_extras.get(CONF_RECEIVER_ENTITY, "")
fields: dict = {}
if receiver:
fields[vol.Optional(CONF_RECEIVER_ENTITY, default=receiver)] = sel["receiver"]
else:
fields[vol.Optional(CONF_RECEIVER_ENTITY)] = sel["receiver"]
current_input = self._setup_extras.get(CONF_RECEIVER_INPUT, "")
if current_input:
fields[vol.Optional(CONF_RECEIVER_INPUT, default=current_input)] = str
else:
fields[vol.Optional(CONF_RECEIVER_INPUT)] = str
# the step_id IS the menu row id: HA dispatches a submitted form by its
# own step_id, so anything else is an UnknownStep on submit
return self.async_show_form(step_id="receiver", data_schema=vol.Schema(fields))
async def async_step_connection(self, user_input=None):
"""Setup sub-screen: the address, the ADB port and the two transports.
The same three settings the first form carries, re-editable together —
the first form is one screen by design (so nothing it used to accept can
disappear), and this menu entry is the way back to the address after a
pairing attempt failed. Merged like every other sub-screen.
"""
if user_input is not None:
self._apply_setup_submission(user_input)
if CONF_HOST in user_input:
self._user_data[CONF_HOST] = str(user_input.get(CONF_HOST) or "").strip()
if CONF_ADB_PORT in user_input:
try:
self._user_data[CONF_ADB_PORT] = int(user_input[CONF_ADB_PORT])
except (TypeError, ValueError):
self._user_data[CONF_ADB_PORT] = DEFAULT_ADB_PORT
for key in (CONF_ADB_ENABLED, CONF_PROTOCOL_ENABLED):
if key in user_input:
self._user_data[key] = bool(user_input[key])
return await self.async_step_menu()
host = self._host()
port = self._user_data.get(CONF_ADB_PORT)
if not isinstance(port, int):
port = DEFAULT_ADB_PORT
fields = {
_host_field_key(CONF_HOST, host): str,
vol.Optional(CONF_ADB_PORT, default=port): int,
vol.Optional(
CONF_ADB_ENABLED, default=bool(self._user_data.get(CONF_ADB_ENABLED, True))
): bool,
vol.Optional(
CONF_PROTOCOL_ENABLED,
default=bool(self._user_data.get(CONF_PROTOCOL_ENABLED, True)),
): bool,
}
return self.async_show_form(step_id="connection", data_schema=vol.Schema(fields))
async def async_step_finish(self, user_input=None):
"""The menu's finish row: create the entry from the first form + sub-screens."""
return self._finish_setup()
def _show_adb_pair_form(self, errors=None):
"""adb_pair form: the address/port (fixable here) and the skip escape.
@@ -805,7 +1086,12 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
# state when no key exists, and the Pair ADB button (only
# registered for adb-enabled entries) pairs later without
# re-adding the integration.
return await self._async_after_pairing()
#
# t56: a skip means the ADB attempt is DECIDED, so the setup
# menu takes over from here — the protocol is still offered on
# it (unpaired), and the user reaches Connection/Receiver/Finish
# without being marched through another form.
return self._show_setup_menu()
typed_host = str(user_input.get(CONF_HOST) or "").strip()
if typed_host:
@@ -831,6 +1117,9 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
host, port, key_path
)
if connected:
# t57 F2: paired, so the menu must stop offering that row —
# the same rule the protocol row already follows
self._paired_adb = True
return await self._async_after_pairing()
else:
errors["base"] = "adb_failed"
@@ -844,13 +1133,24 @@ class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
"""Handle options for Nvidia Shield — single unified page (t29 G3b).
"""Handle options for Nvidia Shield — a menu of sub-screens (t29 G3b, t56).
The constructor takes NO arguments: HA assigns ``self.config_entry``
on instantiation (the pre-2026 "OptionsFlow(config_entry)" pattern is
deprecated). Suggested values come through
``add_suggested_values_to_schema``, and saving reloads via the
OptionsFlowWithReload base — no listener boomerangs.
The constructor takes NO arguments: HA assigns ``self.config_entry`` on
instantiation (the pre-2026 ``OptionsFlow(config_entry)`` pattern is
deprecated) and resolves it through ``hass.config_entries`` at call time.
The page is a standard Home Assistant MENU step: Home Assistant renders the
rows, stores the choice as ``next_step_id``, and calls
``async_step_<choice>(None)``. Every sub-screen merges into ONE pending
options dict and returns to the menu, so an edit made on one sub-screen can
never be lost by visiting another — the merge is key-scoped and
deterministic, never last-writer-wins. "Save and close" commits it (and
reloads through the OptionsFlowWithReload base).
Suggested values come through ``add_suggested_values_to_schema`` and are
handed the SERIALIZED field values from ``_options_field_values``: the apps
map is a dict in storage but must be the documented ``package=name[|icon]``
text on the wire, or the browser renders ``[object Object]`` (t56).
"""
def __init__(self):
@@ -865,154 +1165,390 @@ class NvidiaShieldOptionsFlow(config_entries.OptionsFlowWithReload):
self._options = {}
return self._options
# ── the menu (t56) ──
async def async_step_init(self, user_input=None):
"""Single-page options."""
errors = {}
if user_input is not None:
# t47 F1: a field submitted as None means "not supplied" — only an
# explicit empty value ('' or a cleared selector) clears a setting, so
# the two cannot be confused. Note the schema itself already tolerates
# the empty value (see _ClearingEntitySelector).
user_input = {key: value for key, value in user_input.items() if value is not None}
# P5-2: validate the digit delay (0.05–1.0 s, §7 window)
delay = user_input.pop(CONF_CHANNEL_DIGIT_DELAY, None)
if delay is not None:
try:
delay = float(delay)
except (TypeError, ValueError):
errors["base"] = "invalid_digit_delay"
delay = None
if delay is not None and not (0.05 <= delay <= 1.0):
errors["base"] = "invalid_digit_delay"
# t44: one selector per command, stored as a single dict option so
# the service layer reads one shape. Popped here because _options_dict
# is updated with the rest of user_input verbatim below.
scripts = {
command: script
for command in SCRIPT_COMMANDS
if (
script := str(
user_input.pop(f"{SCRIPT_FIELD_PREFIX}{command}", "") or ""
).strip()
"""The options menu: Main setup / Apps / Scripts / Receiver / Save.
A MENU result never reaches this method with form data: the flow
manager reads ``next_step_id`` off the menu result and calls the chosen
step directly. Returning the menu here is what every sub-screen comes
back to.
``discover`` is the explicit refresh (t57): it runs the ADB sweep and
reports what it found, so the app table is never built by accident and
nothing sweeps on a state poll.
"""
return self.async_show_menu(
step_id="init",
menu_options=[
"connection",
"receiver",
"apps",
"discover",
"scripts",
"finish",
],
description_placeholders=self._menu_placeholders(),
)
}
# t47: one optional script for the remote entity — a plain string
# option, unlike the per-command dict above
remote_script = user_input.get(CONF_REMOTE_COMMAND_SCRIPT)
if remote_script is not None:
user_input[CONF_REMOTE_COMMAND_SCRIPT] = str(remote_script).strip()
app_map_text = user_input.pop(CONF_APP_NAME_MAP, None)
icons: dict[str, str] = {}
if app_map_text is None:
app_map = self._options_dict.get(CONF_APP_NAME_MAP, {})
elif app_map_text.strip() == "":
app_map = {}
else:
app_map = _app_map_from_text(app_map_text, icons)
if app_map is None:
errors["base"] = "invalid_app_map"
if not errors:
if delay is not None:
self._options_dict[CONF_CHANNEL_DIGIT_DELAY] = delay
self._options_dict.update(user_input)
self._options_dict[CONF_APP_NAME_MAP] = app_map
# P5-2: the icon column — the editor is the source of
# truth, so a save without icons clears them (and a
# single-column map stays valid).
self._options_dict[CONF_APP_ICON_MAP] = icons
# an empty dict clears every override (the editor is the truth)
self._options_dict[CONF_COMMAND_SCRIPTS] = scripts
def _menu_placeholders(self) -> dict:
"""What the menu says about the current discovery state (t57)."""
apps = self._discovered_apps()
if apps:
return {"discovery": f"{len(apps)} apps found on the Shield"}
manager = getattr(self.config_entry, "runtime_data", None) if (
self.config_entry is not None
) else None
if manager is not None and not getattr(manager, "connected", False):
return {"discovery": "no ADB connection — nothing discovered yet"}
return {"discovery": "not discovered yet — use Discover installed apps"}
async def async_step_discover(self, user_input=None):
"""Discover the apps installed on the Shield over ADB (t57).
A menu ROW, not a form: Home Assistant dispatches ``next_step_id`` to
this method with no user input, it awaits the manager's bounded sweep,
and the menu comes back with a line saying what was found. Running it
from a row keeps the sweep explicit (never a per-poll cost) and gives it
a place to report a failure without a new field.
"""
await self._run_discovery()
return await self.async_step_init()
async def _run_discovery(self) -> None:
"""Sweep the device, swallowing every failure into the menu's note.
Discovery is a convenience: when ADB is off or down, or the sweep finds
nothing, the menu must still render and the table must still work on the
user's own rows. So nothing here raises — a failure simply leaves the
discovered list empty, which is what the placeholders report.
"""
entry = self.config_entry
manager = getattr(entry, "runtime_data", None) if entry is not None else None
if manager is None:
store = (getattr(self.hass, "data", None) or {}).get(DOMAIN) or {}
manager = store.get(getattr(entry, "entry_id", None))
sweep = getattr(manager, "async_refresh_installed_apps", None)
if sweep is None:
return
try:
await sweep(force=True)
except Exception as err: # noqa: BLE001 - a failed sweep is not an error
_LOGGER.debug("app discovery from the options page failed: %s", err)
async def async_step_menu_again(self):
"""Return to the options menu after a sub-screen saved (t56).
Deliberately NOT ``async_step_init``: HA dispatches a menu row to
``async_step_<row id>`` by NAME, so ``async_step_init`` is not what the
menu returns to — and calling it here would also bypass the ``step_id``
the browser expects on the next render.
"""
return await self.async_step_init()
async def async_step_finish(self, user_input=None):
"""Save and close: commit the merged options (the base reloads)."""
return self.async_create_entry(title="", data=self._options_dict)
# ── the ONE merge rule ──
def _apply_submission(self, user_input: dict, errors: dict) -> bool:
"""Merge a sub-screen submission into the pending options.
The rule, so "cleared" and "untouched" can never be confused:
* a key the submission CARRIES is authoritative — an empty value (``''``
from a cleared selector, ``None`` from an explicit null, a whitespace
string) CLEARS the setting, a value sets it;
* a key the submission does NOT carry is untouched and keeps its stored
value.
Real Home Assistant submits every field the step rendered, so a missing
key only ever means "that field was not on this screen" (verified with a
real FlowManager: the submitted ``user_input`` is exactly the coerced
schema output for the rendered fields). The per-command scripts are the
one nested-shaped option, so when ANY script field is part of a
submission the whole dict is rebuilt from the submitted fields: that is
what makes unsetting a script persist instead of surviving as a stale
key, and it keeps ``command_scripts`` in the shape the service layer
reads (flat ``command -> script``).
Returns True when the submission is valid and merged.
"""
present = {
key: value
for key, value in user_input.items()
if key in _ALL_OPTION_FIELDS
}
# ── the apps table: parse or reject, never half-store ──
if CONF_APP_NAME_MAP in present:
parsed = _app_map_submission(present[CONF_APP_NAME_MAP])
if parsed is None:
errors["base"] = "invalid_app_map"
return False
app_map, icon_map = parsed
present[CONF_APP_NAME_MAP] = app_map
present[CONF_APP_ICON_MAP] = icon_map
# ── the channel digit delay (§7 window) ──
if CONF_CHANNEL_DIGIT_DELAY in present:
try:
delay = float(present[CONF_CHANNEL_DIGIT_DELAY])
except (TypeError, ValueError):
errors["base"] = "invalid_digit_delay"
return False
if not 0.05 <= delay <= 1.0:
errors["base"] = "invalid_digit_delay"
return False
present[CONF_CHANNEL_DIGIT_DELAY] = delay
# ── the per-command scripts: per FIELD, which is the unambiguous rule ──
#
# A cleared selector arrives as an empty value for THAT field (t57
# UNIT 0a verified the shape against real HA), so clearing is a field
# operation: a `script_<command>` key that is present and empty REMOVES
# its command, one that is present with a value sets it, and a key the
# submission does not carry is untouched — the same rule as every other
# option. Nothing depends on the page being complete, so a hand-made
# partial submission cannot silently wipe the commands it omits. The
# starting point is the normalized `command_scripts` view, so a command
# this version no longer knows about is dropped rather than carried.
script_fields = [f for f in present if f.startswith(SCRIPT_FIELD_PREFIX)]
if script_fields:
scripts = dict(command_scripts(self._options_dict))
for field in script_fields:
command = field[len(SCRIPT_FIELD_PREFIX):]
value = str(present[field] or "").strip()
if command in SCRIPT_COMMANDS and value:
scripts[command] = value
else:
# cleared, or a field this version has no command for
scripts.pop(command, None)
# UI only: the service layer reads the dict, never a flat key
present.pop(field)
present[CONF_COMMAND_SCRIPTS] = scripts
for key, value in present.items():
if value is None:
value = ""
if isinstance(value, str):
value = value.strip()
self._options_dict[key] = value
return True
# ── sub-screens ──
async def async_step_connection(self, user_input=None):
"""Main setup: the link, both transports and the linked entities."""
errors: dict = {}
if user_input is not None and self._apply_submission(dict(user_input), errors):
return await self.async_step_menu_again()
return self._show_connection_form(errors)
def _show_connection_form(self, errors=None):
"""Main setup: the same settings the first setup form carries.
Deliberately its own field list rather than ``_user_schema_dict``: that
builder renders the entry's NAME (a config-entry title, not an option —
it would be a field whose edits are silently dropped) and it takes no
logcat/adb-command toggles. The field NAMES stay identical, which is
what a sub-screen has to preserve.
"""
sel = _build_selectors()
schema_dict = {}
options = self._options_dict
port = options.get(CONF_ADB_PORT)
schema_dict = {
_host_field_key(CONF_HOST, str(options.get(CONF_HOST) or "")): str,
vol.Optional(
CONF_ADB_PORT, default=port if isinstance(port, int) else DEFAULT_ADB_PORT
): int,
vol.Optional(
CONF_ADB_ENABLED, default=bool(options.get(CONF_ADB_ENABLED, True))
): bool,
vol.Optional(
CONF_ADB_STORE_APP_INFO,
default=bool(options.get(CONF_ADB_STORE_APP_INFO, True)),
): bool,
vol.Optional(
CONF_PROTOCOL_ENABLED,
default=bool(options.get(CONF_PROTOCOL_ENABLED, True)),
): bool,
vol.Optional(
CONF_LOGCAT_EVENTS, default=bool(options.get(CONF_LOGCAT_EVENTS, False))
): bool,
vol.Optional(
CONF_ADB_COMMAND_ENABLED,
default=bool(options.get(CONF_ADB_COMMAND_ENABLED, False)),
): bool,
vol.Optional(CONF_JELLYFIN_ENTITY): sel["media_player"],
vol.Optional(CONF_POWER_ENTITY): sel["power"],
}
return self._show_sub_form("connection", schema_dict, errors)
# Apps name/icon map editor (P4-1, core androidtv_remote options
# pattern, simplified): one package=name[|icon] per line (P5-2 adds
# the optional icon column).
schema_dict[vol.Optional(CONF_APP_NAME_MAP)] = str
async def async_step_receiver(self, user_input=None):
"""Receiver: the media_player OR switch this Shield plays through."""
errors: dict = {}
if user_input is not None and self._apply_submission(dict(user_input), errors):
return await self.async_step_menu_again()
return self._show_receiver_form(errors)
# Jellyfin
jf = self._options_dict.get(CONF_JELLYFIN_ENTITY)
jf_key = vol.Optional(CONF_JELLYFIN_ENTITY)
if jf:
jf_key = vol.Optional(CONF_JELLYFIN_ENTITY)
schema_dict[jf_key] = sel["media_player"]
# Receiver
recv = self._options_dict.get(CONF_RECEIVER_ENTITY)
recv_key = vol.Optional(CONF_RECEIVER_ENTITY)
if recv:
recv_key = vol.Optional(CONF_RECEIVER_ENTITY)
schema_dict[recv_key] = sel["receiver"]
# t44: command script overrides — the escape hatch for hardware we do not
# drive (a speaker switch owning the volume). Unset = native path as before.
schema_dict[vol.Optional(CONF_REMOTE_COMMAND_SCRIPT)] = sel["script"]
configured_scripts = command_scripts(self._options_dict)
suggested_scripts = {}
for command in SCRIPT_COMMANDS:
field = f"{SCRIPT_FIELD_PREFIX}{command}"
schema_dict[vol.Optional(field)] = sel["script"]
if configured_scripts.get(command):
suggested_scripts[field] = configured_scripts[command]
# P5-2: channel digit behavior (the user-validated §7 flow)
schema_dict[vol.Optional(CONF_CHANNEL_DIGIT_DELAY)] = str
schema_dict[vol.Optional(CONF_CHANNEL_SEND_ENTER)] = bool
# P5-3: the live events push is opt-in (default off; the user
# tests stability on their device and reports)
schema_dict[vol.Optional(CONF_LOGCAT_EVENTS)] = bool
# P4-2a: the raw adb_command service is opt-in (default off)
schema_dict[vol.Optional(CONF_ADB_COMMAND_ENABLED)] = bool
# Receiver input — dropdown from DAG if receiver is set, text otherwise
current_input = self._options_dict.get(CONF_RECEIVER_INPUT, "")
receiver = self._options_dict.get(CONF_RECEIVER_ENTITY)
inputs = _get_receiver_inputs(self.hass, receiver)
if inputs:
input_sel = selector.SelectSelector(
def _show_receiver_form(self, errors=None):
sel = _build_selectors()
# Receiver input: a dropdown from the DAG input map when the linked
# entity publishes one, a plain text field otherwise — a plain switch
# never publishes one, so it gets the text field.
inputs = _get_receiver_inputs(
self.hass, self._options_dict.get(CONF_RECEIVER_ENTITY)
)
input_field = (
selector.SelectSelector(
selector.SelectSelectorConfig(
options=inputs,
mode=selector.SelectSelectorMode.DROPDOWN,
custom_value=True,
)
)
input_key = vol.Optional(CONF_RECEIVER_INPUT)
if current_input:
input_key = vol.Optional(CONF_RECEIVER_INPUT)
schema_dict[input_key] = input_sel
else:
schema_dict[vol.Optional(CONF_RECEIVER_INPUT)] = str
# Power
pwr = self._options_dict.get(CONF_POWER_ENTITY)
pwr_key = vol.Optional(CONF_POWER_ENTITY)
if pwr:
pwr_key = vol.Optional(CONF_POWER_ENTITY)
schema_dict[pwr_key] = sel["power"]
# Transport toggles (t41: both transports are ours; the skip paths and
# this page are the way to turn either one off)
schema_dict[vol.Optional(CONF_ADB_ENABLED)] = bool
schema_dict[vol.Optional(CONF_PROTOCOL_ENABLED)] = bool
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO)] = bool
# The ONE address field (t41), plus the ADB port
schema_dict[vol.Optional(CONF_HOST)] = str
schema_dict[vol.Optional(CONF_ADB_PORT)] = int
# t29 G3b: suggested values come from the entry options via the
# documented helper — no per-key `default=` in the schema build.
if inputs
else str
)
schema_dict = {
vol.Optional(CONF_RECEIVER_ENTITY): sel["receiver"],
vol.Optional(CONF_RECEIVER_INPUT): input_field,
}
return self._show_sub_form("receiver", schema_dict, errors)
async def async_step_apps(self, user_input=None):
"""Apps: the editable apps table — one row per discovered + stored app."""
errors: dict = {}
if user_input is not None and self._apply_submission(dict(user_input), errors):
return await self.async_step_menu_again()
return self._show_apps_form(errors)
def _discovered_apps(self) -> list[dict]:
"""The apps the device reported, from the last background sweep (t57).
Sources, in order: the entry's ``runtime_data`` (where the integration
puts its connection manager) and then the domain store, both of which
the options flow can reach through ``hass``. Never triggers a sweep:
this is a pure read of the manager's TTL cache, so rendering a form
cannot issue shells. When nothing has been discovered (ADB off or down,
or the first sweep still running) the list is empty and the table shows
the user's own rows — the empty state, not a crash.
"""
entry = self.config_entry
manager = getattr(entry, "runtime_data", None) if entry is not None else None
if manager is None:
store = (getattr(self.hass, "data", None) or {}).get(DOMAIN) or {}
manager = store.get(getattr(entry, "entry_id", None))
if manager is None or not hasattr(manager, "installed_apps"):
return []
return manager.installed_apps()
def _show_apps_form(self, errors=None):
stored = self._options_dict
schema_dict = {
# One ROW per line, exactly the stored shape and the documented
# editor format: `package=name` with an optional `|icon`. The rows
# are the user's own map PLUS everything discovery found on the
# device, and the field carries TEXT in every case (never the
# stored dict — that was the [object Object] bug).
vol.Optional(CONF_APP_NAME_MAP): str,
}
discovered = self._discovered_apps()
suggested = _apps_table_text(
stored.get(CONF_APP_NAME_MAP) or {},
stored.get(CONF_APP_ICON_MAP) or {},
discovered,
)
icons = sorted({app.get("icon") for app in discovered if app.get("icon")})
return self._show_sub_form(
"apps",
schema_dict,
errors,
override={CONF_APP_NAME_MAP: suggested},
placeholders={"icons": ", ".join(icons) or "none known"},
)
async def async_step_scripts(self, user_input=None):
"""Scripts: per-command overrides and the channel digit behaviour."""
errors: dict = {}
if user_input is not None and self._apply_submission(dict(user_input), errors):
return await self.async_step_menu_again()
return self._show_scripts_form(errors)
def _show_scripts_form(self, errors=None):
sel = _build_selectors()
schema_dict = {vol.Optional(CONF_REMOTE_COMMAND_SCRIPT): sel["script"]}
for command in SCRIPT_COMMANDS:
schema_dict[vol.Optional(f"{SCRIPT_FIELD_PREFIX}{command}")] = sel["script"]
schema_dict[vol.Optional(CONF_CHANNEL_DIGIT_DELAY)] = str
schema_dict[vol.Optional(CONF_CHANNEL_SEND_ENTER)] = bool
return self._show_sub_form("scripts", schema_dict, errors)
# ── rendering ──
def _show_sub_form(
self,
step_id: str,
schema_dict: dict,
errors: dict | None,
override: dict | None = None,
placeholders: dict | None = None,
):
"""Render a sub-screen with only ITS suggested values — never a dict.
``_options_field_values`` is what serializes the apps map for the wire,
and the script selectors are suggested from the normalized
``command_scripts`` dict — the same accessor the service layer reads, so
the page cannot offer a script the runtime would ignore.
``override`` replaces individual suggested values for a screen whose
suggestion is not simply the stored option (the Apps table is the
stored map MERGED with discovery, t57). ``placeholders`` feeds the
step's ``description_placeholders``, which is how a screen reports
something long — the Discover row's outcome — without inventing a field
for it.
"""
suggested = _options_field_values(self._options_dict)
for command, script in command_scripts(self._options_dict).items():
suggested[f"{SCRIPT_FIELD_PREFIX}{command}"] = script
suggested.update(override or {})
kwargs: dict = {}
if placeholders:
kwargs["description_placeholders"] = dict(placeholders)
return self.async_show_form(
step_id="init",
step_id=step_id,
data_schema=self.add_suggested_values_to_schema(
vol.Schema(schema_dict),
{**self._options_dict, **suggested_scripts},
vol.Schema(schema_dict), suggested
),
errors=errors,
errors=errors or {},
**kwargs,
)
#: Every field the options page can render. A submission key outside this set is
#: menu/HA bookkeeping (`next_step_id`) and must never reach the stored options.
_ALL_OPTION_FIELDS = frozenset(
{
CONF_HOST,
CONF_ADB_PORT,
CONF_ADB_ENABLED,
CONF_PROTOCOL_ENABLED,
CONF_ADB_STORE_APP_INFO,
CONF_LOGCAT_EVENTS,
CONF_ADB_COMMAND_ENABLED,
CONF_JELLYFIN_ENTITY,
CONF_POWER_ENTITY,
CONF_RECEIVER_ENTITY,
CONF_RECEIVER_INPUT,
CONF_APP_NAME_MAP,
CONF_APP_ICON_MAP,
CONF_REMOTE_COMMAND_SCRIPT,
CONF_CHANNEL_DIGIT_DELAY,
CONF_CHANNEL_SEND_ENTER,
CONF_COMMAND_SCRIPTS,
*{f"{SCRIPT_FIELD_PREFIX}{command}" for command in SCRIPT_COMMANDS},
}
)
+389 -15
View File
@@ -36,7 +36,13 @@ from .const import (
ADB_RECONNECT_BACKOFF_CAP,
ADB_RECONNECT_BACKOFF_START,
ADB_RECONNECT_JITTER,
APP_ICON_FALLBACKS,
APP_ICON_GENERIC,
APP_LABEL_EXIT_MARKER,
APP_LABEL_TTL,
APP_LIST_TTL,
APP_SWEEP_CONCURRENCY,
APP_SWEEP_SPACING,
CONF_LOGCAT_EVENTS,
LOGCAT_SUPPRESS_WINDOW,
STATE_STORE_TTL,
@@ -45,6 +51,9 @@ from .const import (
from .ha_adb_common import (
MediaSessionInfo,
foreground_from_logcat,
icon_for_package,
installed_packages,
is_launchable,
parse_application_label,
parse_audio_playback,
)
@@ -206,7 +215,34 @@ class ShieldConnectionManager:
# P5-1: device manifest labels — package → (label, stamp). Long-TTL
# (APP_LABEL_TTL) cache resolved via `pm dump` on demand, one
# package per watchdog cycle so a burst never blocks the watchdog.
# An entry means "a fetch RAN" (t57 UNIT 0): a tuple whose label is
# "" is a definitive negative, and a fetch that could not run is not
# in here at all — so the flag t55 carried to say so was dead weight
# (its only writer always passed True) and is gone.
self._app_labels: dict[str, tuple[str, float]] = {}
self._app_label_requests: set[str] = set()
# t55: packages whose label fetch is queued or in flight — a render
# during the fetch must not enqueue the same package twice.
self._app_label_inflight: set[str] = set()
# t57: the discovered installed-apps table — (rows, stamp), or None when
# no sweep has produced a fresh answer. Only a sweep that RAN writes
# here (t62 F1): a failed or unavailable sweep records itself as UNKNOWN
# instead, because stamping an empty answer would serve "0 apps" — and
# block every retry — for the whole APP_LIST_TTL. Refreshed on demand;
# the watchdog cycle never rebuilds it.
self._installed_apps: tuple[list[dict], float] | None = None
# t62 F1: True when the last sweep could not deliver an answer (no
# poller, ADB down, a shell that raised or answered nothing) — so an
# empty installed_apps() is "unknown", not "this device has no apps".
self._apps_unavailable = False
# ...and the clock stamp of an ATTEMPTED failure (shells really went
# out). A retry then costs two listing shells, so it is spaced by
# APP_SWEEP_RETRY_SECONDS instead of firing on every state poll; an
# unavailable transport costs nothing and retries immediately.
self._apps_failed_at: float | None = None
#: t57: the in-flight sweep, so concurrent callers share one sweep
#: instead of stacking a second burst on the same connection.
self._apps_sweep: asyncio.Task | None = None
# P5-3 (C-STREAM): the dedicated logcat line-stream consumer.
# NEVER shares the poller's ADB socket — its own device object and
# connection; CONF_LOGCAT_EVENTS gates it (default off) and the
@@ -221,7 +257,6 @@ class ShieldConnectionManager:
self._logcat_last_stamp: float = -1e9
# Injectable ladder sleep (t26: deterministic tests / future alt-floors)
self._logcat_ladder_sleep = asyncio.sleep
self._app_label_requests: set[str] = set()
# Injectable clock keeps the TTL expiry tests deterministic.
self._clock = clock
self._tasks: list[asyncio.Task] = []
@@ -548,12 +583,28 @@ class ShieldConnectionManager:
return False
def app_label(self, package: str | None) -> str | None:
"""Cached manifest label for a package (P5-1), or None.
"""Cached manifest label for a package (P5-1), or None (t55).
A cache miss enqueues an on-demand `pm dump` fetch — drained one
package per watchdog cycle via the manager task pool, so entity
refreshes and the watchdog are never blocked by a burst. A failed
resolution simply falls through to the next name tier.
refreshes and the watchdog are never blocked by a burst.
What is cached is the FETCH, not the wishful result (t55 root cause):
* a fetch that RAN caches its outcome for ``APP_LABEL_TTL`` — a label
when the manifest has one, and the empty string otherwise. It is
definitive: the device answered, the package simply has no default
label (several system/OEM packages do not). No re-enqueue, so a
permanently label-less package costs ZERO further round trips
(t24 F1 stays closed).
* a fetch that could NOT run (no poller, ADB down, shell error) caches
nothing, so the next render enqueues again — a miss is retried
visibly instead of being stamped as an eternal "no label". This was
the live defect: the old code stored ("", stamp) on every failure
path, which silenced the label tier for the whole 24 h TTL and left
app_name showing the raw package id.
* while a fetch for the package is queued or in flight, the next
render does not enqueue it a second time.
"""
if not package:
return None
@@ -561,13 +612,58 @@ class ShieldConnectionManager:
if cached is not None:
label, stamp = cached
if self._clock() - stamp <= APP_LABEL_TTL:
# empty label = negative cache (t24 F1): the pm dump ran and
# found nothing — do not re-enqueue, zero per-heartbeat tax
# empty label = negative cache (t24 F1): the pm dump RAN and
# found nothing — do not re-enqueue, zero per-heartbeat tax.
# A fetch that did not run is not cached at all (t57 UNIT 0),
# so there is no "unresolved" shape to fall through on.
return label or None
self._app_labels.pop(package, None) # stale — refetch on demand
if package not in self._app_label_requests and (
package not in self._app_label_inflight
):
self._app_label_requests.add(package)
return None
def _known_label(self, package: str) -> tuple[bool, str]:
"""``(known, label)`` from the label cache — "known" = a fetch RAN (t57).
The accessor the DISCOVERY sweep needs and ``app_label`` deliberately is
not: ``app_label`` answers "what should be displayed", so a definitive
negative (a package with no manifest label) comes back as ``None``,
exactly like a miss. A sweep that used it would re-dump every label-less
package on every run and throw away the 24 h cache it is supposed to
benefit from. Here the two are distinguishable.
"""
cached = self._app_labels.get(package)
if cached is None:
return False, ""
label, stamp = cached
if self._clock() - stamp > APP_LABEL_TTL:
self._app_labels.pop(package, None) # stale — refetch on demand
return False, ""
return True, label
def cache_app_label(self, package: str, label: str | None) -> None:
"""Record a label fetch that RAN, and what it found (t55, t57).
The cache's only writer. ``None``/"" is the negative result devices
genuinely report for label-less packages, and it is definitive: no
further ``pm dump`` for ``APP_LABEL_TTL``. A fetch that did NOT run
must not call this — that was the live defect (see
``_fetch_app_label``).
"""
self._app_labels[package] = (label or "", self._clock())
@property
def app_label_requests(self) -> set[str]:
"""Packages queued for a manifest-label fetch (public, for tests)."""
return set(self._app_label_requests)
@property
def app_label_inflight(self) -> set[str]:
"""Packages whose label fetch is running right now (public, tests)."""
return set(self._app_label_inflight)
async def async_capture_protocol_device_data(self) -> None:
"""Capture (name, manufacturer, model, sw) once (t30 G10)."""
if self._protocol is None or self.protocol_device_data is not None:
@@ -586,24 +682,79 @@ class ShieldConnectionManager:
except Exception as err:
_LOGGER.debug("protocol device info unavailable: %s", err)
async def _fetch_app_label(self, package: str) -> None:
"""One `pm dump` label fetch; failure falls through silently."""
async def _dump_label(self, package: str) -> str | None:
"""One `pm dump` label read: the label, or None for a FAILED fetch.
The ONE place the dump is issued, shared by the on-demand path
(:meth:`_fetch_app_label`) and the discovery sweep, so the t57 UNIT 0
distinction — "the command ran and found no label" versus "the
transport delivered nothing" — cannot drift between them. A successful
fetch is also cached here, so both paths benefit from the 24 h cache and
neither double-stamps.
Returns the label (possibly "") when the dump RAN, and None when it did
not run or delivered no output at all.
The exit marker is a liveness tell, not a status code (t62 nit b): it sits
after a ``head`` pipeline, and the Android shell that runs this has no
PIPESTATUS, so ``$?`` is ``head``'s status rather than the dump's. Nothing
reads its VALUE — ``parse_application_label`` strips the marker line — and
the only property tested here is that a command which RAN leaves non-blank
output even when the device prints no label. That non-blankness is what
separates "ran, no label" from "the transport delivered nothing", which
this method turns into a cached negative versus a retry.
"""
poller = self._poller
if poller is None or not self.connected:
return
_LOGGER.debug(
"app label for %s not fetched (ADB not connected) — will "
"retry on the next render",
package,
)
return None
try:
import shlex
dump = await poller.shell(
f"pm dump {shlex.quote(package)} 2>/dev/null | head -300"
f"pm dump {shlex.quote(package)} 2>/dev/null | head -300; "
f"echo {APP_LABEL_EXIT_MARKER}$?"
)
label = parse_application_label(dump or "")
except Exception as err:
_LOGGER.debug("app label fetch failed for %s: %s", package, err)
return
_LOGGER.debug(
"app label fetch failed for %s (%s) — will retry on the "
"next render",
package,
err,
)
return None
if dump is None or not dump.strip():
# the transport never delivered output: a FAILED fetch, not an
# answer. Caching here is the t55 defect this repairs.
_LOGGER.debug(
"app label fetch for %s returned no output — will retry on "
"the next render",
package,
)
return None
label = parse_application_label(dump)
# F1: label-less pm dumps negatively cache too (empty label) so a
# later cycle performs zero additional pm dumps for the package
self._app_labels[package] = (label or "", self._clock())
# later cycle performs zero additional pm dumps for the package.
self.cache_app_label(package, label)
return label
async def _fetch_app_label(self, package: str) -> None:
"""One on-demand `pm dump` label fetch; stamps ONLY a fetch that RAN.
The on-demand half of the label path (one package per watchdog cycle,
driven by an entity render). The dump itself — including the t57 UNIT 0
distinction between a FAILED fetch and a genuinely label-less package —
lives in :meth:`_dump_label`, so the sweep and this path cannot drift.
"""
self._app_label_inflight.add(package)
try:
await self._dump_label(package)
finally:
self._app_label_inflight.discard(package)
def _on_logcat_app_start(self, package: str) -> None:
"""Stamp FIELD_FOREGROUND instantly from a parsed START (P5-3).
@@ -756,6 +907,228 @@ class ShieldConnectionManager:
self._logcat_task.cancel()
self._logcat_task = None
# ── t57: installed-app discovery ───────────────────────────────────────
#
# MARK — documented, not observed (t62 nit c): the output SHAPES the two
# listing commands rely on (`pm list packages -3` → `package:<id>` lines,
# `cmd package resolve-activity --brief -c …HOME` → `package/activity`) are
# Android's documentation, not a captured run: no real Shield was available
# when discovery was written (t57). Every fixture in
# tests/test_installed_apps.py is built from those shapes and the parsers in
# ``ha_adb_common.installed_apps`` are deliberately tolerant of whitespace
# and ordering. Same mark as the state=6 buffering inference in
# ``media_player.py``.
#: t62 F1: how long after an ATTEMPTED sweep failed before another one is
#: issued. A retry costs two listing shells, so it waits out one state poll
#: (30 s) rather than hammering a silent socket; ``force=True`` bypasses it.
#: An unavailable transport (no poller, ADB down) costs nothing and is
#: retried on the very next update.
APP_SWEEP_RETRY_SECONDS: float = 30.0
def installed_apps(self) -> list[dict]:
"""The discovered apps, ``[{package, name, icon}]``, cached (t57).
Returns the cached table whenever it is fresh, and an EMPTY list when
there is no fresh answer: ADB disabled, not connected, the first sweep
still running, or a sweep that could not run (t62 F1). Empty is
therefore "nothing to show", never by itself "this device has no apps"
— ask :meth:`installed_apps_status` which of the two it is. A caller
that wants the table filled calls
:meth:`async_refresh_installed_apps`; this accessor never blocks and
never issues a shell itself, so a state poll stays free of discovery
work.
"""
if self._installed_apps is None:
return []
rows, stamp = self._installed_apps
if self._clock() - stamp > APP_LIST_TTL:
return [] # stale — a refresh repopulates it, the poll does not
return [dict(row) for row in rows]
@property
def installed_apps_stale(self) -> bool:
"""True when no fresh table is available (including "never swept")."""
if self._installed_apps is None:
return True
_rows, stamp = self._installed_apps
return self._clock() - stamp > APP_LIST_TTL
@property
def installed_apps_status(self) -> str:
"""Why :meth:`installed_apps` may be empty, in one word (t62 F1).
* ``"ok"`` — a sweep RAN and its answer is fresh. An empty table with
this status is a real answer (the device reported no launchable
apps), which is the case the old stamped-empty cache could not say.
* ``"unknown"`` — the last sweep could not deliver an answer (no
poller, ADB down, a shell that raised or answered nothing). Nothing
is known about the device, and the next update retries.
* ``"pending"`` — no sweep has completed yet, or the last answer
expired and a refresh is due.
"""
if not self.installed_apps_stale:
return "ok"
return "unknown" if self._apps_unavailable else "pending"
async def async_refresh_installed_apps(self, force: bool = False) -> list[dict]:
"""(Re)build the installed-app table over ADB, bounded and shared (t57).
One sweep at a time: concurrent callers await the same task rather than
starting a second burst, and a fresh table is returned as-is unless
``force``. The sweep is deliberately NOT part of the watchdog: it is a
handful of shells on demand, not a per-cycle cost.
Failure is not an answer (t62 F1). No poller, a disconnected transport,
a shell that raises, or a sweep that delivered no output records
``unknown`` and stores NO table, so the next non-forced call — an
entity poll or the standard ``update_entity`` action — retries instead
of serving a stamped empty answer for the whole APP_LIST_TTL. A sweep
that reached the device and failed is retried at most once per
``APP_SWEEP_RETRY_SECONDS`` so a silent socket is not hammered; a sweep
that RAN stores its answer, including a genuinely empty list.
"""
if not force and not self.installed_apps_stale:
return self.installed_apps()
if (
not force
and self._apps_failed_at is not None
and self._clock() - self._apps_failed_at < self.APP_SWEEP_RETRY_SECONDS
):
# The previous attempt really issued shells and failed: space the
# retries by one poll interval instead of re-issuing them every
# 30 s on a socket that is not answering (t62 F1).
return self.installed_apps()
if self._apps_sweep is not None and not self._apps_sweep.done():
await asyncio.shield(self._apps_sweep)
return self.installed_apps()
task = self._create_task(self._sweep_installed_apps(), "apps_sweep")
self._apps_sweep = task
try:
rows = await task
finally:
if self._apps_sweep is task:
self._apps_sweep = None
return [dict(row) for row in rows]
async def _sweep_installed_apps(self) -> list[dict]:
"""The sweep itself: list, label, icon — then stamp the cache (t57).
Only a sweep that RAN stamps the cache (t62 F1): every failure path
records ``unknown`` and leaves ``_installed_apps`` alone, so the table
the entity serves is the last one that was real — never a fabricated
empty answer that also blocks the retry for APP_LIST_TTL.
"""
poller = self._poller
if poller is None or not self.connected:
_LOGGER.debug(
"installed-app discovery skipped (ADB unavailable) — the "
"options page and the app list stay on what is already known"
)
self._note_apps_unavailable(attempted=False)
return []
try:
user_output = await poller.shell("pm list packages -3")
launcher_output = await poller.shell(
"cmd package resolve-activity --brief -c "
"android.intent.category.HOME"
)
except Exception as err: # pragma: no cover - shell swallows its own
_LOGGER.debug("installed-app discovery failed: %s", err)
self._note_apps_unavailable(attempted=True)
return []
if user_output is None and launcher_output is None:
# the transport delivered nothing at all: no list is NOT an answer
_LOGGER.debug(
"installed-app discovery got no output — will retry on the "
"next update"
)
self._note_apps_unavailable(attempted=True)
return []
packages = [p for p in installed_packages(user_output, launcher_output)
if is_launchable(p)]
labels = await self._sweep_labels(packages)
rows = [
{
"package": package,
"name": labels.get(package) or package,
"icon": icon_for_package(package, APP_ICON_FALLBACKS, APP_ICON_GENERIC),
}
for package in packages
]
self._installed_apps = (rows, self._clock())
self._apps_unavailable = False
self._apps_failed_at = None
_LOGGER.debug("installed-app discovery found %d apps", len(rows))
return rows
def _note_apps_unavailable(self, *, attempted: bool) -> None:
"""Record a sweep that could not deliver an answer (t62 F1).
``attempted`` says whether shells were actually issued: only then does
the retry window apply, because only then does a retry cost anything.
"""
self._apps_unavailable = True
self._apps_failed_at = self._clock() if attempted else None
async def _sweep_labels(self, packages: list[str]) -> dict[str, str]:
"""Labels for a whole sweep, in a bounded burst (t57).
Deliberately NOT through ``_app_label_requests``: that queue is drained
ONE package per watchdog cycle, which is right for the live entity path
(a render must never fan out into shells) but would take a couple of
minutes for a Shield with ~40 apps. Here the fetches run
``APP_SWEEP_CONCURRENCY`` at a time with a short spacing, so a typical
Shield finishes in seconds while the socket still sees a trickle rather
than a flood.
Cached labels are reused, so a second sweep costs almost nothing; a
failed fetch is NOT cached (t57 UNIT 0) and the app falls back to its
package id for this table while the next sweep retries it.
The check goes through :meth:`_known_label` rather than
:meth:`app_label`, so a package the device has definitively answered
"no label" for is not re-dumped on every sweep.
"""
cached: dict[str, str] = {}
missing: list[str] = []
for package in packages:
known, label = self._known_label(package)
if not known:
missing.append(package)
elif label:
cached[package] = label
# a known NEGATIVE needs no entry: the row falls back to its
# package id, and the cache already holds the definitive answer
if not missing:
return cached
semaphore = asyncio.Semaphore(APP_SWEEP_CONCURRENCY)
async def _one(package: str) -> tuple[str, str | None]:
async with semaphore:
# a small spacing keeps the burst polite on a single socket
await asyncio.sleep(APP_SWEEP_SPACING)
return package, await self._dump_label(package)
results = await asyncio.gather(
*(_one(package) for package in missing), return_exceptions=True
)
for result in results:
if isinstance(result, BaseException):
continue # one bad package never fails the sweep
package, label = result
if label:
cached[package] = label
else:
# None here means the fetch FAILED (nothing cached); the empty
# string means the device answered with no label. Both leave
# the row on its package id, and both keep the app in the list.
cached[package] = ""
return cached
def _drain_app_label_requests(self) -> None:
"""Start at most one label fetch per watchdog cycle."""
if not self._app_label_requests or self._poller is None:
@@ -892,6 +1265,7 @@ class ShieldConnectionManager:
self._last_known.clear()
self._app_labels.clear()
self._app_label_requests.clear()
self._app_label_inflight.clear()
if self._poller is not None:
with contextlib.suppress(Exception):
await self._poller.disconnect()
+46
View File
@@ -121,6 +121,13 @@ CONF_APP_ICON_MAP = "app_icon_map"
# 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"
@@ -144,6 +151,45 @@ DEFAULT_APP_DIRECTORY = {
"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"
@@ -11,6 +11,13 @@ from .const import (
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 .keys import ensure_adb_key
from .logcat_events import foreground_from_logcat
@@ -38,6 +45,11 @@ __all__ = [
"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",
"running_apps_from_sessions",
@@ -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(".")
)
@@ -17,6 +17,14 @@ class MediaSessionInfo:
**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
@@ -25,6 +33,7 @@ class MediaSessionInfo:
package: str | None = None
playing: bool = False
paused: bool = False
buffering: bool = False
position: float | None = None
speed: float | None = None
updated: int | None = None
@@ -81,7 +90,8 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
"""Parse `dumpsys media_session` output for ALL active media sessions.
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:
return []
@@ -89,7 +99,8 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
sessions: list[MediaSessionInfo] = []
current_pkg: str | None = None
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
@@ -138,6 +149,7 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
info = MediaSessionInfo()
info.package = current_pkg
info.playing = current_state == 3
info.buffering = current_state == 6
info.paused = current_state == 2
info.position = current_position
info.speed = current_speed
@@ -158,13 +170,16 @@ def parse_all_media_sessions(dump: str) -> list[MediaSessionInfo]:
current_meta.package = current_pkg
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:
if s.playing:
return 0
if s.paused:
if s.buffering:
return 1
if s.paused:
return 2
return 3
sessions.sort(key=sort_key)
return sessions
@@ -206,8 +221,9 @@ def parse_media_session(
Returns the best match from all active sessions:
1. The session whose package matches the foreground app (if provided)
2. The session that is currently playing (state=3)
3. The session that is paused (state=2)
4. Any active session with metadata
3. The session that is buffering (state=6)
4. The session that is paused (state=2)
5. Any active session with metadata
"""
sessions = parse_all_media_sessions(dump)
if not sessions:
@@ -218,6 +234,5 @@ def parse_media_session(
for s in sessions:
if s.package == foreground_package:
return s
# Already sorted: playing > paused > other
# Already sorted: playing > buffering > paused > other
return sessions[0]
@@ -51,6 +51,8 @@ def running_apps_from_tasks(
if session:
if session.playing:
app["state"] = "playing"
elif session.buffering:
app["state"] = "buffering"
elif session.paused:
app["state"] = "paused"
else:
@@ -66,6 +68,8 @@ def running_apps_from_tasks(
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:
@@ -13,7 +13,7 @@
"adb-shell>=0.4.0",
"androidtvremote2>=0.2.0"
],
"version": "2026.10.11.2",
"version": "2026.10.12",
"zeroconf": [
"_androidtvremote2._tcp.local."
],
+285 -81
View File
@@ -13,6 +13,48 @@ State architecture:
keyevent. The external androidtv_remote entity tier that used to back this is
gone — t41 removed it with the entity — so a command has exactly one owner.
State ladder (t55) — what ``state`` reports, in order. The vocabulary is
EXACTLY ``{playing, paused, buffering, idle, off, on}``; each step is the first
source that has something to say, and the earlier steps always win:
1. OFF — the configured power entity reads "off", or the protocol's
is_on push is False (the device reporting its own power
state). Both OUTRANK media: a stale Jellyfin session must not
resurrect playback on a device that is off.
2. intent — a play/pause/stop this entity just sent (optimistic UI until
a slow Jellyfin poll confirms it; self-heal in 10 s).
3. Jellyfin — STATE_PLAYING / STATE_PAUSED of the configured entity.
4. session — the ADB media session's PlaybackState, including BUFFERING
(Android state 6, inferred from the dump's line shape rather
than observed on hardware). State 3 → playing, 2 → paused.
5. IDLE — ADB says the device is asleep (standby), OR the device is
powered on (protocol is_on True / ADB awake / the plug is on)
and NOTHING is playing. "Powered on, app open, nothing
playing" is IDLE for exactly this reason.
6. ON — NOTHING reported a POWER OR PLAYBACK FACT — not "no data" (t58
UNIT 0, the t59 F2 ruling, which this wording follows). ON is
the "we cannot know" state and the only situation it may
describe, so every source that ANSWERS — even to say "not
playing" — takes the ladder to IDLE at worst, never here:
* a configured Jellyfin entity in ANY state, including idle /
off / standby / unavailable (and it reports "not playing"
without ever masking a live media session, because the
playing/paused tiers above still win);
* a live media session that carries no playback flag, i.e. 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 for an entry where NONE of those exists: no
transport data, no power entity, no Jellyfin entity, no session,
no foreground app. It must never mask a playing/paused/buffering
source — the rule that fixes the live "On overrides my playing
states" report.
Intent tracking bridges the gap between instant commands and slow Jellyfin polling:
1. User presses pause → intent recorded → UI shows paused immediately
2. Command sent over the serving transport → Shield pauses on screen
@@ -36,9 +78,9 @@ from homeassistant.components.media_player import (
MediaType,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_PAUSED, STATE_PLAYING
from homeassistant.const import STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_call_later, async_track_state_change_event
@@ -88,6 +130,11 @@ SERVICE_TO_STATE = {
"media_stop": MediaPlayerState.IDLE,
}
#: t61: the domain a receiver must be in for playback to ENSURE it is on. A
#: receiver in any other domain (an AVR media_player) keeps its own semantics —
#: input selection and attribute enrichment — and is never switched here.
RECEIVER_SWITCH_DOMAIN = "switch"
_BASE_FEATURES = (
MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
@@ -623,30 +670,75 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
return None
@staticmethod
def _session_state(info) -> MediaPlayerState | None:
"""The MediaPlayerState a media session is reporting, or None (t55).
One place decides what a session means, so ``state`` and
``supported_features`` cannot disagree about it. Buffering is checked
FIRST: a session refilling its buffer is mid-playback, and state 6 is
the stock-Android buffering value (an inference from the dump's line
shape — no captured fixture here carries it), so BUFFERING is the
more informative of the two if a firmware ever set 6 together with 3.
"""
if info is None:
return None
if info.buffering:
return MediaPlayerState.BUFFERING
if info.playing:
return MediaPlayerState.PLAYING
if info.paused:
return MediaPlayerState.PAUSED
return None
@property
def state(self) -> MediaPlayerState:
# Check if plug is off first — truly off
"""The playback state — the ladder is documented in the module docstring.
Exactly ``{playing, paused, buffering, idle, off}`` are reported for a
device we have data about; ``ON`` is reserved for the case where NO
source reported a POWER OR PLAYBACK FACT — not merely "no transport is
connected" (t58 UNIT 0, the t59 F2 ruling). A source that exists and
answers "not playing" (Jellyfin idle/off/standby/unavailable, a live
media session with no playback flag, a bare foreground app) HAS reported,
so the ladder stops at IDLE, never ON. ON must also never mask a
playing/paused/buffering source. A powered-on Shield with an app open but
nothing playing reads IDLE, not ON (t55 — the live defect users read as
"On overrides my playing states").
"""
# A source that reported a power or playback fact, even a negative one.
# ON is the "we cannot know" state, so it survives only when nothing at
# all answered — this flag IS the distinction the ruling turns on.
reported = False
# 1. Explicit OFF. The plug being off is definitive; the protocol's
# is_on push is the device reporting its own power state. Either
# one OUTRANKS every media source — a stale Jellyfin session must
# never resurrect playback on a device that is off.
if self._power_entity:
# a CONFIGURED source counts as reporting whatever it says now,
# which is why an unavailable plug yields IDLE rather than ON
reported = True
power_state = self._read(self._power_entity)
if power_state and power_state.state == "off":
return MediaPlayerState.OFF
manager = self._manager
if manager is not None and manager.is_on is False:
return MediaPlayerState.OFF
power = self._is_power_on()
if power is False:
if self._manager is not None and self._manager.is_on is False:
return MediaPlayerState.OFF # protocol says powered off
# ADB says asleep — standby (yellow dot)
return MediaPlayerState.IDLE
if power is None:
return MediaPlayerState.IDLE
# Tier 1: Active intent overrides Jellyfin
# 2. A user-commanded intent is the freshest media truth we have.
if self._intent is not None:
return MediaPlayerState(self._intent.target_state)
# Tier 2: Jellyfin is authoritative
# 3. Jellyfin (slow, but authoritative for its own sessions). A
# configured entity reports in EVERY state it can be in: playing and
# paused are truth, and idle / off / standby / unavailable / missing
# all mean "its session is not playing" — reported, just not
# playable, so the ladder continues WITHOUT an early return (a silent
# Jellyfin must never mask a live media session) and IDLE is where it
# ends (t58 UNIT 0).
if self._jellyfin_entity:
reported = True
jf = self._read(self._jellyfin_entity)
if jf:
if jf.state == STATE_PLAYING:
@@ -654,32 +746,55 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
if jf.state == STATE_PAUSED:
return MediaPlayerState.PAUSED
# Tier 3: ADB media session (non-Jellyfin apps)
adb_info = self._manager.best_session(self.app_id) if self._manager else None
if adb_info:
if adb_info.playing:
return MediaPlayerState.PLAYING
if adb_info.paused:
return MediaPlayerState.PAUSED
# 4. Live media session (non-Jellyfin apps), buffering included. The
# session EXISTING is a report even when it carries no playback flag
# (an idle or paused app still publishes one): that is IDLE, and it is
# the t58 UNIT 0 case that used to fall through to ON.
session = manager.best_session(self.app_id) if manager is not None else None
if session is not None:
reported = True
session_state = self._session_state(session)
if session_state is not None:
return session_state
# Tier 3.5 (P3-3): foreground fallback — an app is open (protocol
# current_app push, else the ADB mResumedActivity parse) but publishes
# no media session. The device is still ON; the now-playing tier
# carries the last-known/channel/audio-hint identity.
if self._manager is not None and self._manager.foreground_app:
return MediaPlayerState.ON
# 5. ADB standby: powered but asleep — still data (the device
# answered), so IDLE, never ON. media_player.STANDBY is deprecated
# (2025-07), so IDLE is the compliant mapping (t22 audit).
if manager is not None and manager.awake is False:
return MediaPlayerState.IDLE
# Tier 4: On but no media
return MediaPlayerState.ON
# 6. The device is on (protocol is_on True, ADB awake, or the power
# plug is on — every one of those is a source that reported
# something). An open app or an awake device reads IDLE, not ON.
# A bare foreground app is a report in its own right: the protocol's
# current-app push or the ADB mResumedActivity parse answered.
if self._is_power_on() is not None:
reported = True
if manager is not None and manager.awake is not None:
reported = True
# EITHER transport's foreground answer counts: the protocol's
# current_app push and the ADB mResumedActivity parse are both
# "something reported", even when neither is playing anything.
if manager is not None and manager.foreground_app is not None:
reported = True
# 7. Nothing anywhere reported a power or playback fact. ON is the "we
# cannot know" state, and the only situation it is allowed to
# describe.
return MediaPlayerState.IDLE if reported else MediaPlayerState.ON
@property
def supported_features(self) -> MediaPlayerEntityFeature:
if self._jf_active():
features = _BASE_FEATURES | _PLAYBACK_FEATURES
else:
# ADB media session also supports playback controls
adb_info = self._manager.best_session(self.app_id) if self._manager else None
if adb_info and (adb_info.playing or adb_info.paused):
# A media session's playback controls include the BUFFERING case:
# pausing/resuming a buffering stream must be offered exactly like
# PLAYING (t55).
session = (
self._manager.best_session(self.app_id) if self._manager else None
)
if session is not None and self._session_state(session) is not None:
features = _BASE_FEATURES | _PLAYBACK_FEATURES
else:
features = _BASE_FEATURES
@@ -718,8 +833,9 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
def _app_directory(self) -> dict:
"""Launchable apps: the user's map, else the curated default (P4-1).
ONE source for the browse tree, the source list and the reverse lookup, so
the dropdown can never offer a name the launch path cannot resolve.
ONE source for the source list and the reverse lookup (t61 removed the
browse tree that used to be the third consumer), so the dropdown can
never offer a name the launch path cannot resolve.
The map is hand-editable, so it is NORMALIZED here — once, so that every
consumer sees the same thing (t51):
@@ -729,9 +845,10 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
"TiviMate", instead of being shown padded and missing every lookup;
* an entry whose name — or whose package — is blank/whitespace-only is
dropped, so no surface can offer a child with no package behind it or a
name no launch can reach (the t48 invariant, extended to the tree);
* two packages normalizing to the same name are BOTH KEPT — the browse
tree reaches each by its own package id, so neither app is hidden — while
name no launch can reach (the t48 invariant, extended to the table);
* two packages normalizing to the same name are BOTH KEPT — each stays
reachable by its own package id, which ``select_source`` accepts (t61:
the browse tree that used to publish those ids is gone) — while
the FIRST in map order wins the name: that is the single entry
source_list offers and the package select_source resolves. Map order is
the user's order, so the winner does not move between rebuilds. "The same
@@ -760,8 +877,7 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
Directory order (the user's map order, or the curated list), so rebuilding
the page does not shuffle it. Blanks are already gone and names already
stripped by ``_app_directory`` (t49/t51) — the normalization lives in that
one accessor so the dropdown, the browse tree and the reverse lookup cannot
disagree.
one accessor so the dropdown and the reverse lookup cannot disagree.
Each name is offered ONCE, compared CASE-INSENSITIVELY (t53), keeping the
first spelling the user typed. Exact duplicates were already collapsed, but
@@ -769,8 +885,7 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
reverse lookup — which is case-insensitive by design (t47) — resolved BOTH
of them to the first entry, so the second row launched the wrong app. One
row per name means a row can only launch the app it shows. The shadowed
package is not lost: the browse tree still lists it under its own id, and
select_source accepts that id.
package is not lost: ``select_source`` accepts its own package id.
"""
seen: set[str] = set()
offered: list[str] = []
@@ -902,6 +1017,21 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
np["media_info"] = self._media_info
return np
def _session_playback_label(self, info) -> str:
"""``playing``/``buffering``/``paused``/``idle`` for a live session (t55).
BUFFERING is its own label rather than a flavour of playing: the state
entity says so, and the now-playing dict must not contradict it. The
session flags themselves stay independent; this only names the state.
"""
if info.buffering:
return "buffering"
if info.playing:
return "playing"
if info.paused:
return "paused"
return "idle"
def _get_adb_now_playing(self) -> dict | None:
"""Build now-playing from ADB media session (non-Jellyfin apps)."""
info = self._manager.best_session(self.app_id) if self._manager else None
@@ -909,9 +1039,9 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
return None
if not self._adb_store_info:
# Privacy mode: show that something is playing but not what
if info.playing or info.paused:
if info.playing or info.paused or info.buffering:
return {
"state": "playing" if info.playing else "paused",
"state": self._session_playback_label(info),
"title": self.app_name or self.app_id or "Playing",
"source": "media_session",
}
@@ -920,7 +1050,7 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
if not info.title:
return None
np = {
"state": "playing" if info.playing else ("paused" if info.paused else "idle"),
"state": self._session_playback_label(info),
"title": info.title,
"source": "media_session",
}
@@ -934,6 +1064,10 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
"""Foreground-tier now-playing (P3-3a): an app is open but publishes
no media session (TiviMate, R2). Title = last known state, else the
last channel entered, else the zero-cost audio-playback hint.
The STATE here is ``idle`` or the last-known session's playback state;
the entity-level ladder reports IDLE for this tier (t55), so the two
agree — a bare foreground app is no longer reported as ON.
"""
manager = self._manager
if manager is None:
@@ -945,8 +1079,8 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
state = "idle"
title = None
info = manager.last_known_session(foreground)
if info is not None and (info.playing or info.paused):
state = "playing" if info.playing else "paused"
if info is not None and (info.playing or info.paused or info.buffering):
state = self._session_playback_label(info)
# Privacy mode: identity stays the app, never the media title
title = None if not self._adb_store_info else info.title
if title is None and not self._adb_store_info:
@@ -963,7 +1097,9 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
# E hint (zero round-trip cost): audio was recently played
title = "Media recently active"
if title is None:
return None # bare foreground app — the state stays ON regardless
# Bare foreground app: no now-playing identity, but the device is
# ON — the entity ladder reports IDLE for this (t55).
return None
return {"state": state, "title": title, "source": "app"}
def _get_power_data(self) -> dict | None:
@@ -1081,8 +1217,67 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
self.hass, self._entry.options, command, variables
)
async def _ensure_receiver_on(self) -> None:
"""Turn a SWITCH receiver on before playback — only when it is off (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 a
``switch.*`` receiver is ENSURED ON by the playback entry points
(:meth:`async_turn_on`, :meth:`async_play_media`,
:meth:`async_select_source`) and nothing else happens to it: no volume,
no toggle, no other switch semantics.
Deliberately narrow, in four ways:
* only a ``switch.*`` receiver is touched — a ``media_player.*``
receiver keeps its behaviour exactly;
* only when the switch reports ``off``. An ``on`` switch issues no
service call at all, so this is idempotent — a state write, a poll or
a second entry point in the same action never re-calls it. A state we
cannot read (missing entity, ``unavailable``, ``unknown``) is left
alone rather than guessed at, with a debug line saying so;
* never called from a state read, a poll, a listener or attribute
rendering, so a receiver that is off while the Shield is idle stays
off — this is a playback action, not a synchronisation;
* a service failure (the entity vanished between the read and the call)
is logged, not raised: a receiver that cannot be switched on must not
stop the Shield from playing.
"""
entity_id = self._receiver_entity
if not entity_id or not entity_id.startswith(f"{RECEIVER_SWITCH_DOMAIN}."):
return
state = self._read(entity_id)
if state is None:
_LOGGER.debug(
"receiver %s is not in the state machine — not switching it",
entity_id,
)
return
if state.state != STATE_OFF:
if state.state != STATE_ON:
_LOGGER.debug(
"receiver %s reports %r — not switching it", entity_id, state.state
)
return
try:
await self.hass.services.async_call(
RECEIVER_SWITCH_DOMAIN,
"turn_on",
{"entity_id": entity_id},
blocking=True,
)
except HomeAssistantError as err:
_LOGGER.debug("receiver %s could not be turned on: %s", entity_id, err)
async def async_turn_on(self):
"""Wake the Shield (only when it reports OFF) — or run the turn_on script."""
"""Wake the Shield (only when it reports OFF) — or run the turn_on script.
A ``switch.*`` receiver is ensured on first (t61): the receiver is where
the Shield's sound comes out, so a playback entry point that wakes the
Shield wakes it too — and only when it is actually off.
"""
await self._ensure_receiver_on()
if await self._script_served("turn_on"):
return
await self._power_key(send_when_on=False)
@@ -1136,8 +1331,9 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
if not wanted:
return None
if wanted in directory:
# an automation may hand us the package directly; that is what the
# browse tree publishes as media_content_id
# an automation may hand us the package directly; that is the id
# this table is keyed by (t61: the browse tree that used to publish
# it is gone, the id itself is unchanged)
return wanted
folded = wanted.casefold()
for package, name in directory.items():
@@ -1154,7 +1350,12 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
directory the source list came from and launched over the shared
``play_media("app", …)`` path, so a universal-media-player replacement gets
the native behaviour. An unknown name is an error, never a silent no-op.
A ``switch.*`` receiver is ensured on first (t61), before the script: a
source selection is the start of playback, and it dispatches internally
so the switch is looked at exactly once per user action.
"""
await self._ensure_receiver_on()
if await self._script_served("select_source", {SCRIPT_VAR_APP_NAME: source}):
return
package = self._source_package(source)
@@ -1164,7 +1365,7 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
translation_key="unknown_source",
translation_placeholders={"source": str(source)},
)
await self.async_play_media(MediaType.APP, package)
await self._dispatch_play_media(MediaType.APP, package)
async def _remote_key_cmd(self, remote_key: str, adb_key: str) -> None:
"""Simple key command without intent bookkeeping: protocol → ADB keyevent."""
@@ -1243,45 +1444,48 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
# ── BrowseMedia app directory (P4-2c) ──
async def async_browse_media(self, media_content_type=None, media_content_id=None):
"""Root lists the apps map (or the curated default) → play_media(app).
"""The media browser is EMPTY on purpose — apps live in the sources (t61).
t37 D1: the standard media_player tree, exactly as core's own
androidtv_remote/media_player.py builds it — ``BrowseMedia`` from
homeassistant.components.media_player, every node carrying an explicit
media_content_id/media_content_type, and the directory naming the
class of its children. ``BrowseMediaSource`` only ever existed in
homeassistant.components.media_source.models (t30 G18's domain
parameter is ITS contract: it builds ``media_source://<domain>`` ids),
so an app-directory node must never claim one — and the import above
removed a name media_player has not exported for a long time, which
made every real browse raise.
The user's report, verbatim: "Media Browser shows Apps. To me it should
probably be integrated with the home assistant media display, keep
select sources for apps". They are right about the surface: HA's media
browser is for BROWSABLE media, while those rows are SOURCES. They stay
where a source belongs — ``source_list``/``source``, the source
dropdown, ``select_source`` and ``play_media("app", …)`` — and that
offer is complete.
So this returns a valid, standard ``media_player.BrowseMedia`` root with
NO children rather than a second, inconsistent copy of the app list:
``BrowseMedia`` (never ``BrowseMediaSource``, which media_player has not
exported since before t37 D1), the device as the root title, a directory
class, and no content id. There is no node to select, so no dead entry
can be offered and a click can never reach a row that cannot launch. An
integration with genuinely browsable media would publish it here; this
one has none.
"""
app_map = self._app_directory
children = [
BrowseMedia(
title=name,
media_class=MediaClass.APP,
media_content_type=MediaType.APP,
media_content_id=pkg,
can_play=True,
can_expand=False,
)
for pkg, name in app_map.items()
]
return BrowseMedia(
title="Apps",
title=self._entry.title or "NVIDIA Shield",
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.APPS,
media_content_id="apps",
children_media_class=MediaClass.APP,
media_content_id=None,
can_play=False,
can_expand=True,
children=children,
can_expand=False,
children=[],
)
# ── play_media (§7): channel digits, app/url launch ──
async def async_play_media(self, media_type: str, media_id: str) -> None:
"""HA's ``play_media`` entry point — ensure the receiver, then dispatch.
The receiver ensure (t61) lives on the PUBLIC path only, so an internal
caller (:meth:`async_select_source`) dispatches without a second look at
the same switch inside one user action.
"""
await self._ensure_receiver_on()
await self._dispatch_play_media(media_type, media_id)
async def _dispatch_play_media(self, media_type: str, media_id: str) -> None:
"""Channel selection by digit number; app/url launches (C-LAUNCH).
Channel path replicates HA core androidtv_remote exactly: digits only,
+119 -3
View File
@@ -12,6 +12,7 @@ from homeassistant.helpers.event import async_track_state_change_event
from .connection import ShieldConnectionManager
from .const import (
CONF_ADB_ENABLED,
CONF_APP_NAME_MAP,
CONF_JELLYFIN_ENTITY,
DOMAIN,
@@ -28,6 +29,10 @@ async def async_setup_entry(
) -> None:
"""Set up the Nvidia Shield sensor entities."""
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):
entities.append(NvidiaShieldNowPlayingSensor(hass, entry))
@@ -41,7 +46,9 @@ class NvidiaShieldSensorBase(SensorEntity):
_attr_has_entity_name = True
_attr_should_poll = False
# G12 (t30): poll-driven attributes never belong in the database
_unrecorded_attributes = frozenset({"app_id", "app_name", "playback_state"})
_unrecorded_attributes = frozenset(
{"app_id", "app_name", "playback_state", "installed_apps"}
)
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
self.hass = hass
@@ -93,10 +100,13 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
self.async_write_ha_state()
def _package(self) -> str | None:
"""The raw foreground package from the manager's store (t41).
"""The raw package of the app that is on screen (t41, t55).
StateStore priority chain (P3-3c): protocol current_app push → ADB
mResumedActivity → ADB recents.
mResumedActivity (both via ``manager.foreground_app``) → the most
recent visible ADB recent task. The recents tier is a last resort for
a moment when neither live signal has reported yet; it keeps the
sensor from going None while ADB is still warming up.
"""
manager = self._manager()
if manager is None:
@@ -117,6 +127,13 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
``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(
@@ -138,6 +155,105 @@ class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
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):
"""What's currently playing via Jellyfin."""
+155 -24
View File
@@ -3,7 +3,7 @@
"step": {
"user": {
"title": "NVIDIA Shield",
"description": "{discovery_note}Set up your NVIDIA Shield. Everything here is optional except one state source \u2014 the Nvidia Remote Protocol or ADB. Link the devices and services you want to use.",
"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": {
"name": "Name",
"jellyfin_entity": "Jellyfin media player",
@@ -18,33 +18,84 @@
},
"data_description": {
"jellyfin_entity": "The Jellyfin media_player for this Shield. Leave blank to detect it automatically.",
"receiver_entity": "The receiver or soundbar this Shield plays through.",
"receiver_entity": "The receiver, soundbar or speaker switch this Shield plays through. Leave blank if there is none.",
"receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure.",
"power_entity": "A smart plug switch, if you want reliable on/off detection.",
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
"adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).",
"host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it only if you need a different address.",
"adb_port": "The ADB port. The standard value is 5555 \u2014 change it only if ADB uses a custom port on your network.",
"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.",
"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": {
"title": "Allow ADB access",
"description": "On the Shield, open Settings > System > Developer options and turn on Network debugging (it may be called ADB debugging). Then select Submit \u2014 the Shield shows an Allow prompt; approve it. Nothing happened? Check the address below and select Submit again. Not now? Turn on 'Continue without ADB pairing' and select Submit.",
"description": "On the Shield, open Settings > System > Developer options and turn on Network debugging (it may be called ADB debugging). Then select Submit — the Shield shows an Allow prompt; approve it. Nothing happened? Check the address below and select Submit again. Not now? Turn on 'Continue without ADB pairing' and select Submit.",
"data": {
"host": "The Shield's IP address",
"adb_port": "ADB port",
"skip_pairing": "Continue without ADB pairing"
},
"data_description": {
"host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it only if the Shield moved or you gave it a different address.",
"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 \u2014 pairing restarts and the Shield shows a new code. To set up without the remote protocol, turn on 'Continue without protocol pairing'.",
"description": "While this step is open the Shield shows a pairing code. Type it below and select Submit. If no code appears on the Shield, check the IP address below and select Submit again — pairing restarts and the Shield shows a new code. To set up without the remote protocol, turn on 'Continue without protocol pairing'.",
"data": {
"pairing_code": "Pairing code shown on the Shield",
"host": "The Shield's IP address",
@@ -52,13 +103,13 @@
},
"data_description": {
"pairing_code": "The code the Shield displays while this step is open.",
"host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it if the Shield moved.",
"skip_pairing": "Finishes setup with the remote protocol off \u2014 it stays off until you pair it. Pair later from this integration's entry menu: choose Reconfigure."
"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 \u2014 the Shield then shows a code."
"description": "The Shield no longer trusts the saved pairing certificate, usually after a factory reset. Select Submit to pair again — the Shield then shows a code."
}
},
"error": {
@@ -67,9 +118,9 @@
"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 \u2014 or turn on 'Continue without protocol pairing' to finish setup without it.",
"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 \u2014 for example ar.tvplayer.tv=TiviMate.",
"invalid_app_map": "Each line must be package=name with a non-empty package (no spaces) and a non-empty name — for example ar.tvplayer.tv=TiviMate.",
"invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds."
},
"abort": {
@@ -77,7 +128,7 @@
"reauth_successful": "Re-paired successfully.",
"reauth_skipped": "Pairing skipped. The saved certificate is still not trusted, so the remote protocol stays unavailable until you pair again.",
"reauth_entry_missing": "This entry no longer exists.",
"reconfigure_skipped": "Nothing changed \u2014 the remote protocol stays off until you pair it. Choose Reconfigure from this integration's entry menu when you are ready.",
"reconfigure_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": {},
@@ -91,7 +142,7 @@
"step": {
"init": {
"title": "NVIDIA Shield settings",
"description": "Settings for this Shield. Saving reloads the integration so the changes take effect.",
"description": "Settings for this Shield. Saving reloads the integration so the change takes effect. {discovery}.",
"data": {
"jellyfin_entity": "Jellyfin media player",
"receiver_entity": "Receiver",
@@ -117,21 +168,98 @@
"remote_command_script": "Remote command script"
},
"data_description": {
"jellyfin_entity": "The Jellyfin media_player for this Shield. Stream details come from the HA Jellyfin integration \u2014 no extra setup needed.",
"receiver_entity": "The receiver or soundbar this Shield plays through.",
"jellyfin_entity": "The Jellyfin media_player for this Shield. Stream details come from the HA Jellyfin integration — no extra setup needed.",
"receiver_entity": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch that playback turns on when it is off.",
"receiver_input": "The receiver input the Shield uses, for example 'Shield'.",
"power_entity": "A smart plug switch, if you want reliable on/off detection.",
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
"adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).",
"host": "The Shield's IP address. Change it only if the Shield moved or you gave it a different address.",
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). Turning it on needs a one-time PIN pairing \u2014 use Reconfigure on the entry to pair.",
"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 \u2014 the same opt-in posture as the core androidtv integration.",
"channel_digit_delay": "Pause between the channel digits entered via play_media(channel) \u2014 0.05 to 1.0 seconds (default 0.1, the tested value).",
"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 \u2014 polling keeps the state correct either way, and the integration falls back to polling if the stream keeps failing.",
"logcat_events": "Detects app changes instantly through a dedicated logcat stream (AdbDevice.streaming_shell). Off by default — polling keeps the state correct either way, and the integration falls back to polling if the stream keeps failing.",
"remote_command_script": "One script for remote.send_command. It replaces the key path when set and receives the call's own parameters as variables: command (the list of key commands), num_repeats, delay_secs and hold_secs."
},
"menu_options": {
"connection": "Main setup",
"receiver": "Receiver",
"apps": "Apps",
"discover": "Discover installed apps",
"scripts": "Scripts",
"finish": "Save and close"
}
},
"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."
}
}
}
@@ -155,19 +283,19 @@
"exceptions": {
"invalid_command": {
"title": "Empty command",
"message": "The command field is empty \u2014 nothing was sent."
"message": "The command field is empty — nothing was sent."
},
"unsupported_target": {
"title": "Unsupported entity for adb_command",
"message": "{entity_id} is not an adb_command participant (its entry must enable the adb_command_enabled option) \u2014 command not sent."
"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 \u2014 command not sent."
"message": "The ADB connection of {entity_id} is down — command not sent."
},
"unknown_source": {
"title": "Unknown source",
"message": "'{source}' is not one of this Shield's apps. Use a name from the source list \u2014 the app names from the Apps map, or the built-in list when no map is set."
"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",
@@ -181,6 +309,9 @@
},
"now_playing": {
"name": "Now playing"
},
"installed_apps": {
"name": "Installed apps"
}
}
}
@@ -3,7 +3,7 @@
"step": {
"user": {
"title": "NVIDIA Shield",
"description": "{discovery_note}Set up your NVIDIA Shield. Everything here is optional except one state source \u2014 the Nvidia Remote Protocol or ADB. Link the devices and services you want to use.",
"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": {
"name": "Name",
"jellyfin_entity": "Jellyfin media player",
@@ -18,33 +18,84 @@
},
"data_description": {
"jellyfin_entity": "The Jellyfin media_player for this Shield. Leave blank to detect it automatically.",
"receiver_entity": "The receiver or soundbar this Shield plays through.",
"receiver_entity": "The receiver, soundbar or speaker switch this Shield plays through. Leave blank if there is none.",
"receiver_input": "The receiver input the Shield uses, for example 'Shield'. Leave blank if you are not sure.",
"power_entity": "A smart plug switch, if you want reliable on/off detection.",
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
"adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).",
"host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it only if you need a different address.",
"adb_port": "The ADB port. The standard value is 5555 \u2014 change it only if ADB uses a custom port on your network.",
"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.",
"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": {
"title": "Allow ADB access",
"description": "On the Shield, open Settings > System > Developer options and turn on Network debugging (it may be called ADB debugging). Then select Submit \u2014 the Shield shows an Allow prompt; approve it. Nothing happened? Check the address below and select Submit again. Not now? Turn on 'Continue without ADB pairing' and select Submit.",
"description": "On the Shield, open Settings > System > Developer options and turn on Network debugging (it may be called ADB debugging). Then select Submit — the Shield shows an Allow prompt; approve it. Nothing happened? Check the address below and select Submit again. Not now? Turn on 'Continue without ADB pairing' and select Submit.",
"data": {
"host": "The Shield's IP address",
"adb_port": "ADB port",
"skip_pairing": "Continue without ADB pairing"
},
"data_description": {
"host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it only if the Shield moved or you gave it a different address.",
"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 \u2014 pairing restarts and the Shield shows a new code. To set up without the remote protocol, turn on 'Continue without protocol pairing'.",
"description": "While this step is open the Shield shows a pairing code. Type it below and select Submit. If no code appears on the Shield, check the IP address below and select Submit again — pairing restarts and the Shield shows a new code. To set up without the remote protocol, turn on 'Continue without protocol pairing'.",
"data": {
"pairing_code": "Pairing code shown on the Shield",
"host": "The Shield's IP address",
@@ -52,13 +103,13 @@
},
"data_description": {
"pairing_code": "The code the Shield displays while this step is open.",
"host": "The Shield's IP address. Pre-filled when the Shield was discovered \u2014 change it if the Shield moved.",
"skip_pairing": "Finishes setup with the remote protocol off \u2014 it stays off until you pair it. Pair later from this integration's entry menu: choose Reconfigure."
"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 \u2014 the Shield then shows a code."
"description": "The Shield no longer trusts the saved pairing certificate, usually after a factory reset. Select Submit to pair again — the Shield then shows a code."
}
},
"error": {
@@ -67,9 +118,9 @@
"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 \u2014 or turn on 'Continue without protocol pairing' to finish setup without it.",
"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 \u2014 for example ar.tvplayer.tv=TiviMate.",
"invalid_app_map": "Each line must be package=name with a non-empty package (no spaces) and a non-empty name — for example ar.tvplayer.tv=TiviMate.",
"invalid_digit_delay": "The digit delay must be between 0.05 and 1.0 seconds."
},
"abort": {
@@ -77,7 +128,7 @@
"reauth_successful": "Re-paired successfully.",
"reauth_skipped": "Pairing skipped. The saved certificate is still not trusted, so the remote protocol stays unavailable until you pair again.",
"reauth_entry_missing": "This entry no longer exists.",
"reconfigure_skipped": "Nothing changed \u2014 the remote protocol stays off until you pair it. Choose Reconfigure from this integration's entry menu when you are ready.",
"reconfigure_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": {},
@@ -91,7 +142,7 @@
"step": {
"init": {
"title": "NVIDIA Shield settings",
"description": "Settings for this Shield. Saving reloads the integration so the changes take effect.",
"description": "Settings for this Shield. Saving reloads the integration so the change takes effect. {discovery}.",
"data": {
"jellyfin_entity": "Jellyfin media player",
"receiver_entity": "Receiver",
@@ -117,21 +168,98 @@
"remote_command_script": "Remote command script"
},
"data_description": {
"jellyfin_entity": "The Jellyfin media_player for this Shield. Stream details come from the HA Jellyfin integration \u2014 no extra setup needed.",
"receiver_entity": "The receiver or soundbar this Shield plays through.",
"jellyfin_entity": "The Jellyfin media_player for this Shield. Stream details come from the HA Jellyfin integration — no extra setup needed.",
"receiver_entity": "The receiver or soundbar this Shield plays through — a media_player, or a plain switch that playback turns on when it is off.",
"receiver_input": "The receiver input the Shield uses, for example 'Shield'.",
"power_entity": "A smart plug switch, if you want reliable on/off detection.",
"adb_enabled": "Reads now-playing information over ADB from any app. Requires Developer options > Network debugging on the Shield.",
"adb_store_app_info": "Shows the title and artist from non-Jellyfin apps. Turn off to show only the app name (privacy mode).",
"host": "The Shield's IP address. Change it only if the Shield moved or you gave it a different address.",
"protocol_enabled": "Adds the all-in-one remote-protocol transport (power, availability, app push and volume readout). Turning it on needs a one-time PIN pairing \u2014 use Reconfigure on the entry to pair.",
"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 \u2014 the same opt-in posture as the core androidtv integration.",
"channel_digit_delay": "Pause between the channel digits entered via play_media(channel) \u2014 0.05 to 1.0 seconds (default 0.1, the tested value).",
"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 \u2014 polling keeps the state correct either way, and the integration falls back to polling if the stream keeps failing.",
"logcat_events": "Detects app changes instantly through a dedicated logcat stream (AdbDevice.streaming_shell). Off by default — polling keeps the state correct either way, and the integration falls back to polling if the stream keeps failing.",
"remote_command_script": "One script for remote.send_command. It replaces the key path when set and receives the call's own parameters as variables: command (the list of key commands), num_repeats, delay_secs and hold_secs."
},
"menu_options": {
"connection": "Main setup",
"receiver": "Receiver",
"apps": "Apps",
"discover": "Discover installed apps",
"scripts": "Scripts",
"finish": "Save and close"
}
},
"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."
}
}
}
@@ -155,19 +283,19 @@
"exceptions": {
"invalid_command": {
"title": "Empty command",
"message": "The command field is empty \u2014 nothing was sent."
"message": "The command field is empty — nothing was sent."
},
"unsupported_target": {
"title": "Unsupported entity for adb_command",
"message": "{entity_id} is not an adb_command participant (its entry must enable the adb_command_enabled option) \u2014 command not sent."
"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 \u2014 command not sent."
"message": "The ADB connection of {entity_id} is down — command not sent."
},
"unknown_source": {
"title": "Unknown source",
"message": "'{source}' is not one of this Shield's apps. Use a name from the source list \u2014 the app names from the Apps map, or the built-in list when no map is set."
"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",
@@ -181,6 +309,9 @@
},
"now_playing": {
"name": "Now playing"
},
"installed_apps": {
"name": "Installed apps"
}
}
}
+53 -3
View File
@@ -67,9 +67,18 @@ def install_selector_faces(selector_module) -> None:
installers in the flow tests), so the stubs cannot drift apart.
"""
selector_module.EntitySelector = EntitySelectorStub
selector_module.EntitySelectorConfig = lambda *args, **kwargs: ""
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 = lambda **kwargs: ""
selector_module.SelectSelectorConfig = _Config
selector_module.SelectSelectorMode = types.SimpleNamespace(DROPDOWN="dropdown")
@@ -79,6 +88,8 @@ class MediaPlayerState(enum.StrEnum):
IDLE = "idle"
PLAYING = "playing"
PAUSED = "paused"
STANDBY = "standby"
BUFFERING = "buffering"
class MediaPlayerEntityFeature(enum.IntFlag):
@@ -387,6 +398,10 @@ def install() -> None:
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",
@@ -598,8 +613,43 @@ def load_entity_modules() -> dict:
}
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."""
"""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)
+113 -13
View File
@@ -35,7 +35,8 @@ dependency against an INSTALLED homeassistant:
(``extra_state_attributes``, ``available``, …) declared as properties — HA
merges them with ``attr |= self.extra_state_attributes``, so a plain method
raises ``TypeError: 'method' object is not iterable`` on every state write and
the entity can never render (t40 live install: ``media_player.py:828``). The
the entity can never render (t40 live install: ``media_player.py``'s
``extra_state_attributes`` — a property today, so the number rotted). The
stub suites call the method happily; this pass is pure AST and, unlike 1-3,
needs no homeassistant at all;
5. the package's OWN relative imports (t41): ``from .helpers import X`` must
@@ -44,10 +45,16 @@ dependency against an INSTALLED homeassistant:
harness loaded that file the ImportError waited for a user's log — pass 1
cannot see it (it only judges ``homeassistant.*``). Also pure AST, with its
own entry point ``scan_package_imports()`` for the fast suite.
6. unreachable statements (t62 F2): a statement after
``return``/``raise``/``continue``/``break`` in the SAME block can never run.
``connection.py`` carried a full duplicated copy of the label-drain body
(docstring included) after its ``return cached`` — valid Python, dead, and
invisible to every other pass here, so it shipped. Pure AST, with its own
entry point ``scan_unreachable()``.
Both AST passes run before the homeassistant availability check, and the file
All three AST passes run before the homeassistant availability check, and the file
walk is recursive (``_iter_python_files``), so a platform or helper one
directory down cannot hide from either of them.
directory down cannot hide from any of them.
Known limits (deliberate, and why the sweep still catches the real classes):
``**kwargs`` splats cannot be checked statically, calls on objects other than
@@ -96,8 +103,16 @@ SHIELD_DIR = (
HA_CALL_OWNERS: dict[str, tuple[str, str]] = {
"async_update_reload_and_abort": ("homeassistant.config_entries", "ConfigFlow"),
"async_show_form": ("homeassistant.config_entries", "ConfigFlow"),
"async_create_entry": ("homeassistant.config_entries", "ConfigFlow"),
# t57 UNIT 0a: the FLOW-DISPLAY API surface. These are the calls that decide
# what the user sees, and a wrong keyword here is not a style error — it is a
# TypeError the moment a user submits a form, on every path through it.
# `async_show_menu` takes NO `errors` (HA 2026.10.1: step_id, menu_options,
# sort, description_placeholders), which is precisely the bug this guards.
"async_show_menu": ("homeassistant.data_entry_flow", "FlowHandler"),
"async_show_progress": ("homeassistant.data_entry_flow", "FlowHandler"),
"async_show_progress_done": ("homeassistant.data_entry_flow", "FlowHandler"),
"async_abort": ("homeassistant.config_entries", "ConfigFlow"),
"async_create_entry": ("homeassistant.config_entries", "ConfigFlow"),
"async_set_unique_id": ("homeassistant.config_entries", "ConfigFlow"),
"_abort_if_unique_id_configured": (
"homeassistant.config_entries",
@@ -112,17 +127,28 @@ HA_CALL_OWNERS: dict[str, tuple[str, str]] = {
}
#: Non-vacuity floors — the ONE source of truth for the standalone entry point
#: and the pytest wrapper (t39 F1). They sit below the current walk (13 modules,
#: 60 imports, 35 checked arguments, 7 awaited HA calls) so a broken or
#: mis-targeted walk fails loudly instead of printing zeros and exiting 0.
#: and the pytest wrapper (t39 F1). They sit below the current walk (24 modules,
#: 58 imports, 179 relative imports, 46 checked arguments, 7 awaited HA calls,
#: 1102 statement blocks) so a broken or mis-targeted walk fails loudly instead of
#: printing zeros and exiting 0.
FLOORS: dict[str, int] = {
# t41: the sweep is recursive, so the floor sits above the top-level-only
# count (13) — a regression to the shallow glob trips it.
"modules": 20,
"imports": 40,
"relative_imports": 100,
"call_args": 20,
# t62 F2: raised from 20 to just under the current 46. A floor's job is to
# notice an API OWNER going away, not only a broken walk: at 20, deleting
# `async_show_menu` from HA_CALL_OWNERS left 40 checked arguments and still
# looked green — only the pre-fix fixture tripped it. At 44 every owner that
# carries a real settlement argument is load-bearing (show_form, show_menu,
# abort, create_entry, update_entry). The floor sits two below the count so a
# one-call-site refactor does not redden the gate.
"call_args": 44,
"awaited_calls": 5,
# t62 F2: the unreachable-statement pass walks ~1100 statement blocks. A
# floor here means a pass that silently stopped parsing cannot look green.
"statement_blocks": 500,
}
#: The interpreter floor Home Assistant itself declares: PyPI metadata for
@@ -159,7 +185,7 @@ class Report:
relative_imports: int = 0
call_args: int = 0
awaited_calls: int = 0
statement_blocks: int = 0
def floor_violations(self, floors: dict[str, int] | None = None) -> list[str]:
"""Counters below the non-vacuity floors — a sweep nobody should trust.
@@ -179,6 +205,7 @@ class Report:
"no Home-Assistant API drift "
f"({self.modules} modules, {self.imports} imports, "
f"{self.relative_imports} relative imports, "
f"{self.statement_blocks} statement blocks, "
f"{self.call_args} call arguments, "
f"{self.awaited_calls} awaited HA calls)"
)
@@ -682,7 +709,8 @@ def parse_requires_python(spec: str | None) -> tuple[int, ...] | None:
#: HA: ``Entity.__async_calculate_state`` merges ``extra_state_attributes`` with
#: ``attr |= self.extra_state_attributes``, which raises
#: ``TypeError: 'method' object is not iterable`` on every state write. That was
#: the t40 live-install blocker (``media_player.py:828``).
#: the t40 live-install blocker — ``media_player.py``'s
#: ``extra_state_attributes``, a property today (the old line number rotted).
ENTITY_ATTRIBUTES = frozenset(
{
"activity",
@@ -850,7 +878,77 @@ def scan_entity_contracts(paths=None) -> list[Finding]:
_scan_entity_contracts(paths, report)
return report.findings
# ------------------------------------------------------------ unreachable code
#: Statements that end the block they sit in: nothing AFTER one of these, in the
#: SAME block, can ever run. ``return``/``raise`` end the block outright;
#: ``continue``/``break`` end this pass through the enclosing loop, which is the
#: same thing for the statements that follow them there.
_TERMINAL_STATEMENTS = (ast.Return, ast.Raise, ast.Continue, ast.Break)
def _statement_blocks(node: ast.AST):
"""Every list-of-statements a node owns (bodies, ``orelse``, ``finalbody``)."""
for _field, value in ast.iter_fields(node):
if not isinstance(value, list) or not value:
continue
if all(isinstance(item, ast.stmt) for item in value):
yield value
def _scan_unreachable(tree: ast.AST, path: str, report: Report) -> None:
"""t62 F2: flag statements that sit after a terminal one in the same block.
Per-block on purpose: a statement in a DIFFERENT block (an ``else``, an
``except`` handler, a nested function) is not dead, and flagging those would
make this pass noise. What it does see is the shape that shipped twice — a
duplicated body left after ``return cached`` inside the same function
(connection.py's label drain) — which is valid Python, invisible to every
other gate here, and simply never executed.
"""
for node in ast.walk(tree):
for block in _statement_blocks(node):
report.statement_blocks += 1
for index, statement in enumerate(block[:-1]):
if not isinstance(statement, _TERMINAL_STATEMENTS):
continue
dead = block[index + 1]
report.findings.append(
Finding(
path,
dead.lineno,
"unreachable-statement",
f"{type(statement).__name__.lower()} at line "
f"{statement.lineno} ends this block, so this statement "
"can never run (duplicated or leftover code)",
)
)
break
def _scan_unreachable_tree(paths, report: Report) -> None:
"""Walk the tree and run the unreachable-statement pass over every file."""
for root in paths:
root = Path(root)
if not root.exists():
continue # scan() reports bad-path for the same target
for file in _iter_python_files(root):
try:
tree = ast.parse(file.read_text(encoding="utf-8"), filename=str(file))
except (OSError, SyntaxError):
continue
_scan_unreachable(tree, file.name, report)
def scan_unreachable(paths=None) -> list[Finding]:
"""The unreachable-statement pass on its own (t62 F2) — the wrapper's proof."""
if paths is None:
paths = [SHIELD_DIR]
elif isinstance(paths, (str, Path)):
paths = [paths]
report = Report()
_scan_unreachable_tree(paths, report)
return report.findings
# ----------------------------------------------------------------------- scan
@@ -867,11 +965,13 @@ def scan(paths=None) -> Report:
paths = [paths]
report = Report()
# t40/t41: pure-AST checks, so they run even without homeassistant — that is
# the whole point of a contract stubs cannot enforce (and a relative import
# needs no library to be wrong).
# t40/t41/t62: pure-AST checks, so they run even without homeassistant —
# that is the whole point of a contract stubs cannot enforce (a relative
# import needs no library to be wrong, and dead code needs no API to be
# dead).
_scan_entity_contracts(paths, report)
_scan_package_imports(paths, report)
_scan_unreachable_tree(paths, report)
if not homeassistant_available():
# Neither a false green nor a wall of phantom findings: with no package
# to resolve against, every import would "fail", so the sweep reports
+784 -47
View File
@@ -9,13 +9,14 @@ layer marks which tier served each command.
import ast
import asyncio
import sys
from types import SimpleNamespace
import entity_stublib
import pytest
from conftest import _SHIELD_DIR
from entity_stublib import install, load_entity_modules
from nsh.connection import ShieldConnectionManager
from nsh.const import TIVIMATE_PACKAGE
from nsh.const import APP_LABEL_EXIT_MARKER, TIVIMATE_PACKAGE
@pytest.fixture(autouse=True)
@@ -117,7 +118,11 @@ def make_player(modules, manager=None, jellyfin_entity=None, options=None):
player._entity_tasks = set()
player._channel_task = None
player.async_write_ha_state = lambda: None
player._read = lambda eid: None
# t61: FakeHass carries the real `states` shape, so the entity's own `_read`
# is the one under test — an entity no test set still reads as None.
# t61: `__init__` gives the entity an (empty) power-sensor map; the harness
# mirrors it so the attribute payload can be read here too.
player._power_sensors = {}
# t41: nothing may leave this integration, so an HA service call is now the
# failure signal rather than a supported tier. `served` stays empty unless a
@@ -429,7 +434,7 @@ def _ladder_player(modules, foreground=None, audio=None):
class TestForegroundLadderTier:
"""P3-3a: the ladder branch between the media-session tier and ON."""
"""P3-3a/t55: the ladder branch between the media-session tier and IDLE."""
def _player(self, modules, sessions=None, foreground=None, audio=None):
player, _s, _i = make_player(modules)
@@ -466,8 +471,9 @@ class TestForegroundLadderTier:
audio=["ar.tvplayer.tv"],
)
# TiviMate publishes no session (R2) — fg tier keeps the device ON
assert player.state.value == "on"
# TiviMate publishes no session (R2) — an open app, nothing playing:
# the t55 ladder reports IDLE, never ON ("On" is reserved for no data)
assert player.state.value == "idle"
np = player._get_app_now_playing()
assert np == {
"state": "idle",
@@ -478,7 +484,7 @@ class TestForegroundLadderTier:
async def test_no_session_app_without_hint_returns_none(self, modules):
player, _m = self._player(modules, foreground="com.spocky.projengmenu")
assert player.state.value == "on" # launcher foreground → still ON
assert player.state.value == "idle" # launcher open, nothing playing
assert player._get_app_now_playing() is None
async def test_last_known_title_beats_audio_hint(self, modules):
@@ -516,6 +522,314 @@ class TestForegroundLadderTier:
assert np == {"state": "idle", "title": "101", "source": "app"}
class _MpState:
"""A HA State-shaped stand-in for the power entity / Jellyfin entity."""
def __init__(self, state, attributes=None):
self.state = state
self.attributes = attributes or {}
def _state_player(
modules,
*,
awake=None,
sessions=None,
foreground=None,
protocol_on=None,
power_entity_state=None,
jellyfin_state=None,
jellyfin_configured=False,
):
"""A player whose power/media sources are exactly what the test sets.
``None`` means "this source has not reported" — the distinction the t55
ladder turns on, so every test spells out which sources spoke.
``jellyfin_configured`` covers the entity being configured while ``_read``
still answers None (the entity is gone).
"""
player, _served, _intents = make_player(modules)
manager = player._manager
if awake is not None:
manager.store.set("awake", awake)
if sessions is not None:
manager.store.set("sessions", sessions)
if foreground is not None:
manager.store.set("foreground", foreground)
if protocol_on is not None:
manager.store.set("is_on", protocol_on)
entry = player._entry
entry.options = dict(entry.options or {})
if power_entity_state is not None:
entry.options["power_entity"] = "switch.shield_plug"
if jellyfin_state is not None or jellyfin_configured:
entry.options["jellyfin_entity"] = "media_player.jellyfin"
def _read(entity_id):
if entity_id == "switch.shield_plug" and power_entity_state is not None:
return _MpState(power_entity_state)
if entity_id == "media_player.jellyfin" and jellyfin_state is not None:
return _MpState(jellyfin_state, {"media_title": "Movie"})
return None
player._read = _read
return player, manager
class TestStateModelT55:
"""t55: exactly {playing, paused, buffering, idle, off} + ON for no data.
The live defect: ``state`` returned ON for "powered on with an open app but
nothing playing", which users read as "On overrides my playing states" and
which sat ABOVE the media tiers in one branch. Every source that reports
playing/paused/buffering must win, and ON must only describe the case where
no source reported anything at all.
"""
# ── the five states ──
async def test_no_data_at_all_is_the_only_on(self, modules):
player, _m = _state_player(modules)
assert player.state.value == "on"
async def test_protocol_off_is_off(self, modules):
player, _m = _state_player(modules, protocol_on=False)
assert player.state.value == "off"
async def test_power_entity_off_is_off_even_with_a_session(self, modules):
"""Explicit OFF outranks a stale media source."""
from nsh.ha_adb_common import MediaSessionInfo
player, _m = _state_player(
modules,
awake=True,
power_entity_state="off",
sessions=[MediaSessionInfo(package="com.netflix.ninja", playing=True)],
)
assert player.state.value == "off"
async def test_open_app_with_nothing_playing_is_idle(self, modules):
"""The headline fix: ON no longer masks "on but nothing playing"."""
player, _m = _state_player(
modules, awake=True, foreground="ar.tvplayer.tv", protocol_on=True
)
assert player.state.value == "idle"
async def test_powered_on_with_no_app_is_idle(self, modules):
player, _m = _state_player(modules, protocol_on=True)
assert player.state.value == "idle"
async def test_power_entity_on_is_idle_not_on(self, modules):
"""A plug reporting 'on' IS a source — ON is reserved for no data."""
player, _m = _state_player(modules, power_entity_state="on")
assert player.state.value == "idle"
async def test_adb_standby_is_idle(self, modules):
player, _m = _state_player(modules, awake=False)
assert player.state.value == "idle"
# ── each source outranks the idle/on fallbacks ──
async def test_jellyfin_playing_outranks_the_idle_fallback(self, modules):
player, _m = _state_player(
modules, awake=True, foreground="ar.tvplayer.tv", jellyfin_state="playing"
)
assert player.state.value == "playing"
async def test_jellyfin_playing_outranks_no_power_data(self, modules):
"""No transport, no power entity — Jellyfin still knows better than ON."""
player, _m = _state_player(modules, jellyfin_state="playing")
assert player.state.value == "playing"
async def test_media_session_buffering_outranks_the_idle_fallback(self, modules):
from nsh.ha_adb_common import MediaSessionInfo
player, _m = _state_player(
modules,
awake=True,
foreground="com.netflix.ninja",
sessions=[
MediaSessionInfo(package="com.netflix.ninja", buffering=True)
],
)
assert player.state.value == "buffering"
async def test_media_session_playing_outranks_the_idle_fallback(self, modules):
from nsh.ha_adb_common import MediaSessionInfo
player, _m = _state_player(
modules,
awake=True,
foreground="com.netflix.ninja",
sessions=[MediaSessionInfo(package="com.netflix.ninja", playing=True)],
)
assert player.state.value == "playing"
async def test_media_session_paused_outranks_the_idle_fallback(self, modules):
from nsh.ha_adb_common import MediaSessionInfo
player, _m = _state_player(
modules,
awake=True,
foreground="com.netflix.ninja",
sessions=[MediaSessionInfo(package="com.netflix.ninja", paused=True)],
)
assert player.state.value == "paused"
async def test_intent_playing_outranks_the_idle_fallback(self, modules):
player, _m = _state_player(modules, awake=True, foreground="ar.tvplayer.tv")
player._intent = SimpleNamespace(target_state="playing")
assert player.state.value == "playing"
async def test_live_session_beats_a_stale_cached_one(self, modules):
"""Session state 3/4 keep behaving as before: live truth, then cache."""
from nsh.ha_adb_common import MediaSessionInfo
player, manager = _state_player(
modules,
awake=True,
foreground="com.netflix.ninja",
sessions=[MediaSessionInfo(package="com.netflix.ninja", paused=True)],
)
info = MediaSessionInfo(package="com.netflix.ninja", playing=True, title="Live")
manager._last_known["com.netflix.ninja"] = (info, manager._clock())
assert player.state.value == "paused" # the live session, not the cache
# ── vocabulary guard: no other value can be produced ──
def test_the_vocabulary_is_exactly_the_five_states_plus_on(self, modules):
from pathlib import Path
from nsh import media_player as mp
# Checked on the AST (the t37 idiom), not on prose: every state the
# module can return is in the documented vocabulary, and the
# media-tier values really are all used.
tree = ast.parse(Path(mp.__file__).read_text(encoding="utf-8"))
used = {
node.attr
for node in ast.walk(tree)
if isinstance(node, ast.Attribute)
and isinstance(node.value, ast.Name)
and node.value.id == "MediaPlayerState"
}
assert used <= {"PLAYING", "PAUSED", "BUFFERING", "IDLE", "OFF", "ON"}
assert {"PLAYING", "PAUSED", "BUFFERING", "IDLE", "OFF"} <= used
# The documented vocabulary itself, as the user reads it on the card.
assert {s.lower() for s in used} == {
"playing",
"paused",
"buffering",
"idle",
"off",
"on",
}
async def test_on_never_masks_a_reported_power_state(self, modules):
"""Every power-reporting shape must be off or idle — never on."""
shapes = [
{"protocol_on": True},
{"protocol_on": False},
{"awake": True},
{"awake": False},
{"power_entity_state": "on"},
{"power_entity_state": "off"},
]
for kwargs in shapes:
player, _m = _state_player(modules, **kwargs)
assert player.state.value != "on", kwargs
# ── BUFFERING: state, controls, now-playing ──
async def test_buffering_advertises_the_playback_controls(self, modules):
from entity_stublib import MediaPlayerEntityFeature
from nsh.ha_adb_common import MediaSessionInfo
player, _m = _state_player(
modules,
awake=True,
foreground="com.netflix.ninja",
sessions=[MediaSessionInfo(package="com.netflix.ninja", buffering=True)],
)
features = player.supported_features
assert features & MediaPlayerEntityFeature.PLAY
assert features & MediaPlayerEntityFeature.PAUSE
assert features & MediaPlayerEntityFeature.STOP
async def test_no_session_still_means_base_features_only(self, modules):
from entity_stublib import MediaPlayerEntityFeature
player, _m = _state_player(modules, awake=True, foreground="ar.tvplayer.tv")
assert not (player.supported_features & MediaPlayerEntityFeature.PLAY)
async def test_buffering_now_playing_is_named_buffering(self, modules):
from nsh.ha_adb_common import MediaSessionInfo
player, _m = _state_player(
modules,
awake=True,
foreground="com.netflix.ninja",
sessions=[
MediaSessionInfo(
package="com.netflix.ninja", buffering=True, title="Movie"
)
],
)
np = player._get_adb_now_playing()
assert np["state"] == "buffering"
assert np["title"] == "Movie"
async def test_buffering_survives_privacy_mode(self, modules):
from nsh.ha_adb_common import MediaSessionInfo
player, _m = _state_player(
modules,
awake=True,
foreground="com.netflix.ninja",
sessions=[
MediaSessionInfo(
package="com.netflix.ninja", buffering=True, title="Secret"
)
],
)
player._entry.options["adb_store_app_info"] = False
np = player._get_adb_now_playing()
assert np["state"] == "buffering"
assert np["title"] != "Secret"
async def test_last_known_buffering_reaches_the_foreground_tier(self, modules):
from nsh.ha_adb_common import MediaSessionInfo
player, manager = _state_player(modules, awake=True, foreground="ar.tvplayer.tv")
manager._clock = lambda: 0.0
info = MediaSessionInfo(
package="ar.tvplayer.tv", buffering=True, title="Sky News"
)
manager._last_known["ar.tvplayer.tv"] = (info, 0.0)
np = player._get_app_now_playing()
assert np == {"state": "buffering", "title": "Sky News", "source": "app"}
class TestChannelLeakGuard:
"""F1: the channel number never leaks to a non-channel foreground app."""
@@ -1145,9 +1459,10 @@ class TestFaithfulHaStubs:
class TestBrowseMediaDirectory:
"""P4-2c: the app directory root and its launch path."""
"""t61: the HA media browser publishes NO media — apps are sources."""
async def test_root_lists_the_options_map(self, modules):
async def test_the_browser_is_an_empty_root(self, modules):
"""The user's report: apps must not appear in the media browser."""
player, _s, _i = make_player(modules)
player._entry.options["app_name_map"] = {
"ar.tvplayer.tv": "TiviMate",
@@ -1156,53 +1471,56 @@ class TestBrowseMediaDirectory:
root = await player.async_browse_media()
assert root.title == "Apps"
assert root.can_play is False and root.can_expand is True
ids = [(c.media_content_id, c.title) for c in root.children]
assert ids == [
("ar.tvplayer.tv", "TiviMate"),
("com.netflix.ninja", "Netflix"),
]
assert all(c.media_content_type == "app" for c in root.children)
# t37 D1: a standard media_player.BrowseMedia tree — explicit
# media_content_id/-type at every level, the directory naming the class
# of its children, and NO media_source `domain` (that parameter belongs
# to media_source.models.BrowseMediaSource, which also builds the
# media_source:// ids).
assert root.children == [] # not one app row
assert root.can_play is False
assert root.can_expand is False # there is nothing to open
assert root.title == "mdarcy" # the device, not a second app list
# t37 D1: still a standard media_player.BrowseMedia root — explicit
# class/type, NO media_source `domain` (that parameter belongs to
# media_source.models.BrowseMediaSource, which also builds the
# media_source:// ids and is not exportable from media_player at all).
assert root.media_class == "directory"
assert root.media_content_id == "apps"
assert root.media_content_type == "apps"
assert root.children_media_class == "app"
assert root.media_content_id is None # no node to select
assert root.children_media_class is None
assert not hasattr(root, "domain")
assert [c.media_class for c in root.children] == ["app", "app"]
# ...and it serialises into the payload the frontend consumes
payload = root.as_dict()
assert payload["media_content_id"] == "apps"
assert payload["children_media_class"] == "app"
assert payload["children"][0]["media_content_id"] == "ar.tvplayer.tv"
assert payload["children"][0]["media_content_type"] == "app"
assert payload["children"][0]["can_play"] is True
assert payload["children"] == []
assert payload["can_expand"] is False
async def test_root_falls_back_to_the_curated_default(self, modules):
player, _s, _i = make_player(modules) # no apps map configured
root = await player.async_browse_media()
ids = [c.media_content_id for c in root.children]
assert "ar.tvplayer.tv" in ids # TiviMate
assert "com.github.damontecres.wholphin" in ids # Wholphin
assert "com.netflix.ninja" in ids # Netflix
assert "com.google.android.youtube.tv" in ids # YouTube
async def test_selection_launches_via_play_media_app(self, modules):
async def test_apps_stay_reachable_through_the_sources(self, modules):
"""What the browser no longer offers, source_list/select_source still do."""
player, _s, _i = make_player(modules)
protocol = player._manager._protocol
player._entry.options["app_name_map"] = {
"ar.tvplayer.tv": "TiviMate",
"com.netflix.ninja": "Netflix",
}
root = await player.async_browse_media()
selected = root.children[0]
await player.async_play_media("app", selected.media_content_id)
assert root.children == []
assert player.source_list == ["TiviMate", "Netflix"]
assert protocol.launch_calls == [selected.media_content_id] # C-LAUNCH
protocol = player._manager._protocol
await player.async_select_source("Netflix")
assert protocol.launch_calls == ["com.netflix.ninja"] # still launchable
async def test_any_rebrowse_id_stays_a_valid_empty_root(self, modules):
"""A frontend that re-browses an id cannot make this crash or lie."""
player, _s, _i = make_player(modules)
for content_type, content_id in (
(None, None),
("apps", "apps"),
("app", "ar.tvplayer.tv"),
("channel", "12"),
):
root = await player.async_browse_media(content_type, content_id)
assert root.children == []
assert root.media_content_id is None
class DownAdbPoller(FakeAdbPoller):
@@ -1259,7 +1577,7 @@ class TestCurrentAppSensorFriendlyName:
def test_cached_manifest_label_is_the_second_tier(self, modules):
player, _served, _intents = make_player(modules)
manager = player._manager
manager._app_labels["com.netflix.ninja"] = ("Netflix", manager._clock())
manager.cache_app_label("com.netflix.ninja", "Netflix")
sensor = self._sensor(modules, player)
manager._on_current_app("com.netflix.ninja")
@@ -1270,7 +1588,7 @@ class TestCurrentAppSensorFriendlyName:
def test_recent_apps_fallback_resolves_through_the_label_tier(self, modules):
player, _served, _intents = make_player(modules)
manager = player._manager
manager._app_labels["ar.tvplayer.tv"] = ("TiviMate", manager._clock())
manager.cache_app_label("ar.tvplayer.tv", "TiviMate")
manager.store.set("running_apps", [{"package": "ar.tvplayer.tv"}])
sensor = self._sensor(modules, player)
@@ -1299,6 +1617,299 @@ class TestCurrentAppSensorFriendlyName:
assert sensor.native_value == player.app_name == "TiviMate"
class _LabelPoller(FakeAdbPoller):
"""FakeAdbPoller + the surface a real watchdog cycle needs.
``is_awake``/``poll_media_sessions``/``all_sessions``/``poll_running_apps``
make ``_refresh`` a harmless cycle that still reaches the
``_drain_app_label_requests`` call, so a test drives exactly the
one-fetch-per-cycle behaviour the label tier depends on.
"""
def __init__(self):
super().__init__()
self.pm_dumps: dict[str, str] = {}
self.dumps: list[str] = []
#: t57: True = the transport never delivered output (a FAILED fetch)
self.shell_failure = False
@property
def connected(self):
# FakeAdbPoller's property is always True; restated so this class reads
# on its own and the watchdog cycle spends no retry on connect.
return True
async def is_awake(self) -> bool:
return True
async def poll_media_sessions(self, foreground_package=None, head_lines=200):
return None
@property
def all_sessions(self) -> list:
return []
@property
def last_dump(self) -> str:
return ""
async def poll_running_apps(self) -> list[dict]:
return []
async def shell(self, cmd: str) -> str | None:
"""Model BOTH real outcomes of a label dump (t57).
A package in ``pm_dumps`` answers with its dump. A package that is NOT
there answers with the exit marker and nothing else — the shape a real
transport produces for "the command ran and this package has no
label". ``shell_failure`` turns every dump into the OTHER real shape:
the transport never delivered output at all (``adb_client.shell``
returns None when it is not connected or the command raised).
"""
self.dumps.append(cmd)
import shlex
if self.shell_failure:
return None
for package, dump in self.pm_dumps.items():
if shlex.quote(package) in cmd or package in cmd:
return dump
if APP_LABEL_EXIT_MARKER in cmd:
return f"{APP_LABEL_EXIT_MARKER}0\n"
return None
class TestCurrentAppNameEndToEndT55:
"""t55: the label fetch is driven off the CURRENT app, and a miss retries.
The live symptom was the Current App sensor (and app_name) showing a
package id. These drive the real chain: sensor render → app_label cache
miss → the fetch the manager enqueues → the next watchdog cycle drains it
for the package that is actually on screen, NOT some earlier app.
"""
def _wire(self, modules, current_app, dumps):
player, _served, _intents = make_player(modules)
manager = player._manager
poller = _LabelPoller()
poller.pm_dumps.update(dumps)
manager._poller = poller
player._entry.runtime_data = manager
sensor = make_app_sensor(modules, manager, player._entry)
manager._on_current_app(current_app)
return player, manager, sensor, poller
async def test_known_app_resolves_by_fetching_its_label(self, modules):
player, manager, sensor, poller = self._wire(
modules, "com.netflix.ninja", {"com.netflix.ninja": "application-label: 'Netflix'"}
)
# first render: cache miss → the sensor falls through to the package
assert sensor.native_value == "com.netflix.ninja"
assert manager.app_label_requests == {"com.netflix.ninja"}
# the watchdog drains the fetch for the CURRENT package
await manager._watchdog_cycle(5.0)
await asyncio.sleep(0)
assert poller.dumps and "com.netflix.ninja" in poller.dumps[0]
# next render: the resolved device name, in the sensor AND the player
assert sensor.native_value == "Netflix"
assert sensor.extra_state_attributes["app_name"] == "Netflix"
assert player.app_name == "Netflix"
async def test_mapped_app_never_needs_the_device_label(self, modules):
player, manager, sensor, poller = self._wire(
modules, "ar.tvplayer.tv", {"ar.tvplayer.tv": "application-label: 'TiviMate'"}
)
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate TV"}
assert sensor.native_value == "TiviMate TV"
await manager._watchdog_cycle(5.0)
await asyncio.sleep(0)
# the explicit map wins and no pm dump was issued for it
assert sensor.native_value == "TiviMate TV"
assert not [d for d in poller.dumps if "pm dump" in d]
async def test_unknown_app_falls_through_and_the_next_poll_retries(self, modules):
"""The requirement, end to end: no permanent miss for an unresolvable app.
A failed fetch must not be cached as an answer: the next poll tries
again. Only the fetch that RAN may stamp the negative.
"""
player, manager, sensor, poller = self._wire(
modules, "com.example.unknown", {}
)
real_shell = poller.shell
failures = {"n": 0}
async def _flaky(cmd):
if "pm dump" in cmd:
failures["n"] += 1
raise RuntimeError("adb shell blew up")
return await real_shell(cmd)
poller.shell = _flaky
# poll 1: fetch fails → still the package id, nothing cached
assert sensor.native_value == "com.example.unknown"
await manager._watchdog_cycle(5.0)
await asyncio.sleep(0)
assert sensor.native_value == "com.example.unknown"
assert failures["n"] == 1
assert "com.example.unknown" not in manager._app_labels
# poll 2: the render after the failure RE-ENQUEUES it — the retry is
# driven by the next render, exactly like the live poll cycle does
assert manager.app_label("com.example.unknown") is None
assert manager.app_label_requests == {"com.example.unknown"}
await manager._watchdog_cycle(5.0)
await asyncio.sleep(0)
assert failures["n"] == 2
assert "com.example.unknown" not in manager._app_labels
# poll 3: the shell answers, the dump carries no label → a definitive
# negative is cached and the sensor keeps falling through to the id
poller.shell = real_shell
assert manager.app_label("com.example.unknown") is None
await manager._watchdog_cycle(5.0)
await asyncio.sleep(0)
assert sensor.native_value == "com.example.unknown"
assert "com.example.unknown" in manager._app_labels
@pytest.mark.parametrize("blank", [None, ""], ids=["none", "empty"])
async def test_a_dump_with_no_output_is_a_failure_not_an_answer(
self, modules, blank
):
"""t59 F1 (HIGH): the live symptom, driven through the SENSOR.
``adb_client.shell`` swallows its error and returns None, and a real
dump can also come back as an empty string. Neither is the device
saying "this package has no label": reading either as an answer stamped
a 24 h definitive negative after ONE transient failure, so the sensor
stayed on the package id, exactly one dump was ever attempted, and a
later GOOD dump could not recover the label. Both shapes are driven
here — the pre-fix tree fails this at the second cycle.
"""
player, manager, sensor, poller = self._wire(
modules, "ar.tvplayer.tv", {"ar.tvplayer.tv": "application-label: 'TiviMate'"}
)
async def _blank(_cmd):
poller.dumps.append(_cmd)
return blank
# cycle 1: the transport delivered nothing → a FAILED fetch
poller.shell = _blank
assert sensor.native_value == "ar.tvplayer.tv"
await manager._watchdog_cycle(5.0)
await asyncio.sleep(0)
assert "ar.tvplayer.tv" not in manager._app_labels # nothing stamped
assert sensor.native_value == "ar.tvplayer.tv"
# the next render re-enqueues, and the retry is what recovers the label
assert manager.app_label("ar.tvplayer.tv") is None
assert manager.app_label_requests == {"ar.tvplayer.tv"}
dump_count = len([d for d in poller.dumps if "pm dump" in d])
real_shell = _LabelPoller.shell.__get__(poller, _LabelPoller)
poller.shell = real_shell
await manager._watchdog_cycle(5.0)
await asyncio.sleep(0)
# a later GOOD dump recovers it — the pre-fix cache made this impossible
assert len([d for d in poller.dumps if "pm dump" in d]) == dump_count + 1
assert sensor.native_value == "TiviMate"
assert sensor.extra_state_attributes["app_name"] == "TiviMate"
assert player.app_name == "TiviMate"
@pytest.mark.parametrize("blank", [None, ""], ids=["none", "empty"])
async def test_the_player_recovers_the_label_after_a_failed_fetch(
self, modules, blank
):
"""Same defect, driven through ``media_player.app_name`` (t59 F1).
The sensor and the player read the same chain, but they are two
different surfaces and the reviewer's reproduction observed BOTH
stuck on the package id — so both are pinned.
"""
player, manager, sensor, poller = self._wire(
modules, "com.netflix.ninja", {"com.netflix.ninja": "application-label: 'Netflix'"}
)
async def _blank(_cmd):
poller.dumps.append(_cmd)
return blank
poller.shell = _blank
assert player.app_name == "com.netflix.ninja"
await manager._watchdog_cycle(5.0)
await asyncio.sleep(0)
assert "com.netflix.ninja" not in manager._app_labels
assert player.app_name == "com.netflix.ninja"
assert manager.app_label("com.netflix.ninja") is None # re-enqueued
poller.shell = _LabelPoller.shell.__get__(poller, _LabelPoller)
await manager._watchdog_cycle(5.0)
await asyncio.sleep(0)
assert player.app_name == "Netflix"
assert sensor.native_value == "Netflix"
async def test_a_genuinely_label_less_package_is_still_negatively_cached(
self, modules
):
"""The other half: a dump that RAN and found no label stays definitive.
This is what keeps the t24 F1 rule closed — a label-less package costs
zero further round trips. The marker in the output is what separates
this from the failed fetch above.
"""
player, manager, sensor, poller = self._wire(modules, "com.oem.blank", {})
assert sensor.native_value == "com.oem.blank"
await manager._watchdog_cycle(5.0)
await asyncio.sleep(0)
assert "com.oem.blank" in manager._app_labels # a definitive negative
assert manager.app_label("com.oem.blank") is None
assert manager.app_label_requests == set() # no re-enqueue, ever
dumps = [d for d in poller.dumps if "pm dump" in d]
await manager._watchdog_cycle(5.0)
await asyncio.sleep(0)
assert [d for d in poller.dumps if "pm dump" in d] == dumps # zero more
async def test_the_fetch_targets_the_app_on_screen_not_the_previous_one(self, modules):
player, manager, sensor, poller = self._wire(
modules,
"ar.tvplayer.tv",
{
"ar.tvplayer.tv": "application-label: 'TiviMate'",
"com.google.android.youtube.tv": "application-label: 'YouTube'",
},
)
assert sensor.native_value == "ar.tvplayer.tv" # miss for the 1st app
manager._on_current_app("com.google.android.youtube.tv")
assert sensor.native_value == "com.google.android.youtube.tv"
# both packages are queued; the watchdog drains ONE per cycle
await manager._watchdog_cycle(5.0)
await asyncio.sleep(0)
await manager._watchdog_cycle(5.0)
await asyncio.sleep(0)
# every drained fetch names a package, and the app that is CURRENT is
# the one the sensor now shows the label of
assert "ar.tvplayer.tv" in poller.dumps[0]
assert "com.google.android.youtube.tv" in poller.dumps[1]
assert sensor.native_value == "YouTube"
class TestAvailabilityLadder:
"""t40: ADB enabled-but-unreachable + a healthy protocol = AVAILABLE.
@@ -1378,3 +1989,129 @@ class TestAvailabilityLadder:
assert transport["entity_available"] is True
assert transport["adb"] == {"configured": True, "connected": False, "awake": None}
assert transport["protocol"]["active"] is True
class TestReportingFactsT58:
"""t58 UNIT 0 — the t59 F2 ruling: ON = "no power or playback fact reported".
The live report was "On overrides my playing states"; t55 fixed the loud half
(a powered-on Shield with an app open). This is the quiet half: a source that
ANSWERS "not playing" was still falling through to ON. Every case below is a
source that answered, so the ladder must stop at IDLE.
"""
# ── Jellyfin, in every state it can report ──
async def test_jellyfin_idle_is_idle_not_on(self, modules):
player, _m = _state_player(modules, jellyfin_state="idle")
assert player.state.value == "idle"
async def test_jellyfin_off_is_idle_not_on(self, modules):
"""`off` is a REPORT about the session, not the Shield's own power:
only the configured power entity and the protocol push can say OFF."""
player, _m = _state_player(modules, jellyfin_state="off")
assert player.state.value == "idle"
async def test_jellyfin_standby_is_idle_not_on(self, modules):
player, _m = _state_player(modules, jellyfin_state="standby")
assert player.state.value == "idle"
async def test_jellyfin_unavailable_is_idle_not_on(self, modules):
"""The one the ruling names explicitly: unavailable still answered."""
player, _m = _state_player(modules, jellyfin_state="unavailable")
assert player.state.value == "idle"
async def test_a_configured_but_missing_jellyfin_entity_is_idle(self, modules):
"""Configured IS the report: we are watching a source that exists.
`_read` returns None here (the entity is not in the state machine), and
the old ladder fell straight through to ON — for an entry whose owner
had explicitly configured a media source.
"""
player, _m = _state_player(modules, jellyfin_configured=True)
assert player.state.value == "idle"
# ── a live session with no playback flag, and a bare foreground app ──
async def test_a_session_with_no_playback_flag_is_idle_not_on(self, modules):
"""The app publishes a session; that it is NOT playing is the report."""
from nsh.ha_adb_common import MediaSessionInfo
player, _m = _state_player(
modules, sessions=[MediaSessionInfo(package="com.netflix.ninja")]
)
assert player.state.value == "idle"
async def test_a_bare_foreground_app_is_idle_not_on(self, modules):
"""The protocol's current-app push answered — nothing else did."""
player, _m = _state_player(modules, foreground="ar.tvplayer.tv")
assert player.state.value == "idle"
# ── the negative control: ON is still reachable, just narrowly ──
async def test_on_survives_when_truly_nothing_reported(self, modules):
player, _m = _state_player(modules)
assert player.state.value == "on"
async def test_on_is_unreachable_with_any_source_configured(self, modules):
"""The rule as a shape: each single source takes ON off the table."""
for kwargs in (
{"jellyfin_state": "idle"},
{"jellyfin_state": "off"},
{"jellyfin_state": "standby"},
{"jellyfin_state": "unavailable"},
{"jellyfin_configured": True},
{"foreground": "ar.tvplayer.tv"},
{"protocol_on": True},
{"awake": True},
{"power_entity_state": "on"},
):
player, _m = _state_player(modules, **kwargs)
assert player.state.value != "on", kwargs
# ── a silent source must not MASK a live one (the ladder keeps going) ──
async def test_idle_jellyfin_does_not_mask_a_playing_session(self, modules):
from nsh.ha_adb_common import MediaSessionInfo
player, _m = _state_player(
modules,
jellyfin_state="idle",
sessions=[MediaSessionInfo(package="com.netflix.ninja", playing=True)],
foreground="com.netflix.ninja",
)
assert player.state.value == "playing"
async def test_unavailable_jellyfin_does_not_mask_a_paused_session(self, modules):
from nsh.ha_adb_common import MediaSessionInfo
player, _m = _state_player(
modules,
jellyfin_state="unavailable",
sessions=[MediaSessionInfo(package="com.netflix.ninja", paused=True)],
foreground="com.netflix.ninja",
)
assert player.state.value == "paused"
async def test_an_off_power_entity_still_outranks_a_playing_session(self, modules):
"""The t55 rule is untouched by the F2 correction."""
from nsh.ha_adb_common import MediaSessionInfo
player, _m = _state_player(
modules,
power_entity_state="off",
sessions=[MediaSessionInfo(package="com.netflix.ninja", playing=True)],
)
assert player.state.value == "off"
+16
View File
@@ -69,6 +69,19 @@ def _install_import_stubs() -> None:
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}
@@ -212,6 +225,9 @@ async def test_pairing_submit_uses_declared_signature(mocked_test_connect):
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"
+19
View File
@@ -110,6 +110,19 @@ def _install_stubs() -> None:
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}
@@ -338,6 +351,10 @@ class TestProtocolPairing:
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"
@@ -354,6 +371,8 @@ class TestProtocolPairing:
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]
+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
+104 -6
View File
@@ -18,7 +18,7 @@ from nsh.connection import (
ShieldConnectionManager,
StateStore,
)
from nsh.const import ADB_POLL_INTERVAL
from nsh.const import ADB_POLL_INTERVAL, APP_LABEL_EXIT_MARKER
def test_connection_module_depends_on_no_homeassistant():
@@ -177,11 +177,18 @@ class TestAppLabelCache:
assert manager.app_label("com.unknown.app") is None
await manager._watchdog_cycle(5.0)
# still None (next tier), no crash — falls through cleanly
assert manager.app_label("com.unknown.app") is not None or True
# 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_negatively_cached_no_repeat_dump(self):
"""t26 F1: unresolvable packages cost at most one pm dump ever."""
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"
@@ -201,6 +208,94 @@ class TestAppLabelCache:
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
@@ -215,8 +310,11 @@ class TestAppLabelCache:
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"pm dump {shlex.quote(wicked)} 2>/dev/null | head -300; "
f"echo {APP_LABEL_EXIT_MARKER}$?"
]
+122
View File
@@ -44,6 +44,22 @@ PRE_FIX_SOURCES = {
" 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"
@@ -106,6 +122,7 @@ def test_the_sweep_flags_the_pre_fix_defects(tmp_path):
("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)
@@ -290,6 +307,12 @@ _REAL_HA_MATRIX = (
{"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"),
)
@@ -358,3 +381,102 @@ def test_the_real_ha_matrix_covers_every_entity_class_in_the_tree():
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()
)
+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")]
+93
View File
@@ -125,6 +125,76 @@ class TestPlaybackStatePosition:
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)."""
@@ -160,6 +230,29 @@ class TestAudioPlaybackSection:
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
+36 -39
View File
@@ -78,17 +78,20 @@ class TestSourceList:
assert player.source_list == ["TiviMate", "Netflix"]
assert "" not in player.source_list
async def test_the_list_matches_the_browse_titles_exactly(self, modules):
"""Same accessor, same order, same bytes — a blank entry excepted (t49)."""
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)
root = await player.async_browse_media()
assert player.source_list == [child.title for child in root.children]
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)."""
@@ -144,10 +147,7 @@ class TestSource:
def test_it_uses_the_cached_device_label_when_unmapped(self, modules):
player, _served = _player(modules, foreground="com.netflix.ninja")
player._manager._app_labels["com.netflix.ninja"] = (
"Netflix",
player._manager._clock(),
)
player._manager.cache_app_label("com.netflix.ninja", "Netflix")
assert player.source == "Netflix"
@@ -282,6 +282,11 @@ class TestNormalizedDirectories:
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 = {
@@ -291,25 +296,21 @@ class TestNormalizedDirectories:
"": "Ghost",
}
async def test_a_blank_entry_never_reaches_the_browse_tree(self, modules):
"""The Browse tree and the dropdown are the same, resolved offer."""
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))
root = await player.async_browse_media()
assert [child.title for child in root.children] == ["TiviMate"]
assert [child.media_content_id for child in root.children] == [TIVIMATE]
assert list(player._app_directory) == [TIVIMATE]
assert list(player._app_directory.values()) == ["TiviMate"]
assert player.source_list == ["TiviMate"]
async def test_a_blank_package_is_dropped_too(self, modules):
"""An entry with no package is a child that cannot be launched."""
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"}
)
root = await player.async_browse_media()
assert [child.media_content_id for child in root.children] == [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):
@@ -318,11 +319,9 @@ class TestNormalizedDirectories:
app_map={TIVIMATE: " TiviMate ", "com.netflix.ninja": "Netflix"},
)
root = await player.async_browse_media()
# display and match key are one string, everywhere
assert player.source_list == ["TiviMate", "Netflix"]
assert [child.title for child in root.children] == ["TiviMate", "Netflix"]
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
@@ -338,13 +337,11 @@ class TestNormalizedDirectories:
modules, app_map={"com.a": "Netflix", "com.b": " Netflix "}
)
root = await player.async_browse_media()
# both are still offered in the tree, each by its own package id
assert [(c.media_content_id, c.title) for c in root.children] == [
("com.a", "Netflix"),
("com.b", "Netflix"),
]
# 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"
@@ -366,10 +363,11 @@ class TestNormalizedDirectories:
modules, app_map={TIVIMATE: "TiviMate Pro", "com.netflix.ninja": "Netflix"}
)
root = await player.async_browse_media()
assert player._app_directory == {
TIVIMATE: "TiviMate Pro",
"com.netflix.ninja": "Netflix",
}
assert player.source_list == ["TiviMate Pro", "Netflix"]
assert [child.title for child in root.children] == ["TiviMate Pro", "Netflix"]
assert player._source_package("TiviMate Pro") == TIVIMATE
@@ -389,12 +387,11 @@ class TestCaseInsensitiveNames:
# one row, keeping the first spelling the user typed
assert player.source_list == ["Dup"]
# ...and the Browse tree still holds both apps, each under its own id
root = await player.async_browse_media()
assert [(c.media_content_id, c.title) for c in root.children] == [
("com.a", "Dup"),
("com.b", "dup"),
]
# ...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"})
+9
View File
@@ -61,3 +61,12 @@ def test_the_replacement_guide_retires_the_old_stack(readme_text):
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 == []
+135 -19
View File
@@ -27,6 +27,8 @@ from nsh.const import (
CONF_ANDROIDTV_ENTITY,
CONF_HOST,
CONF_PROTOCOL_ENABLED,
SCRIPT_COMMANDS,
SCRIPT_FIELD_PREFIX,
)
_STUB_NAMES = (
@@ -101,6 +103,19 @@ def _install_stubs() -> None:
"description_placeholders": dict(description_placeholders 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}
@@ -136,12 +151,29 @@ def _install_stubs() -> None:
raise DiscoveryDedupAbortError("already_in_progress")
class OptionsFlow:
def async_show_form(self, *, step_id, data_schema=None, errors=None):
def async_show_form(
self, *, step_id, data_schema=None, errors=None,
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_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):
@@ -157,6 +189,9 @@ def _install_stubs() -> None:
return schema
class OptionsFlowWithReload(_SuggestedMixin):
# t56: the MENU surface is shared with OptionsFlow, like async_show_form.
async_show_menu = OptionsFlow.async_show_menu
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)
@@ -476,6 +511,82 @@ class TestMigrationDropsTheEntityKey:
assert module.NvidiaShieldConfigFlow.VERSION == ENTRY_VERSION == 3
_OPTIONS_SUBSTEP_FIELDS = {
"connection": {
CONF_HOST,
CONF_ADB_PORT,
CONF_ADB_ENABLED,
CONF_PROTOCOL_ENABLED,
"adb_store_app_info",
"logcat_events",
"adb_command_enabled",
"jellyfin_entity",
"power_entity",
},
"receiver": {"receiver_entity", "receiver_input"},
"apps": {"app_name_map", "app_icon_map"},
"scripts": {
"remote_command_script",
"channel_digit_delay",
"channel_send_enter",
"command_scripts",
*{f"{SCRIPT_FIELD_PREFIX}{command}" for command in SCRIPT_COMMANDS},
},
}
def _options_step_of(key: str) -> str:
for step, fields in _OPTIONS_SUBSTEP_FIELDS.items():
if key in fields:
return step
if key.startswith(SCRIPT_FIELD_PREFIX):
return "scripts"
raise AssertionError(f"no options sub-screen owns {key!r}")
async def _options_submit(flow, payload: dict, *, save=False):
"""Drive the t56 options MENU into the sub-screens owning `payload` keys.
Returns the last step result, so an error assertion reads the same as it did
when the page was a single form. With ``save=True`` the menu's "Save and
close" row is chosen afterwards, returning the create_entry result.
"""
grouped: dict[str, dict] = {}
for key, value in payload.items():
grouped.setdefault(_options_step_of(key), {})[key] = value
result = await flow.async_step_init(None)
for step, sub in grouped.items():
assert step in result["menu_options"], result["menu_options"]
result = await getattr(flow, f"async_step_{step}")(sub)
if save:
assert "finish" in result["menu_options"], result["menu_options"]
result = await flow.async_step_finish(None)
return result
async def _options_open(flow, step: str):
"""Choose an options sub-screen from the menu and open its form."""
result = await flow.async_step_init(None)
assert step in result["menu_options"], result["menu_options"]
return await getattr(flow, f"async_step_{step}")(None)
async def _setup_finish(flow, result):
"""Walk the t56 setup MENU to the entry, starting from a step result.
Chooses any pairing row it needs to (the flow's menu_options are the source
of truth) and then Finish, returning the create_entry result.
"""
while result["type"] == "menu":
choice = (
"finish"
if "finish" in result["menu_options"]
else result["menu_options"][0]
)
result = await getattr(flow, f"async_step_{choice}")(None)
return result
def _cmd_player(modules):
"""Standalone media_player entity via the command-routing harness."""
import test_command_routing as routing
@@ -496,7 +607,7 @@ class TestAppIconColumn:
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
await flow.async_step_init({"app_name_map": "ar.tvplayer.tv=TiviMate|http://i.local/t.png"})
await _options_submit(flow, {"app_name_map": "ar.tvplayer.tv=TiviMate|http://i.local/t.png"})
assert flow._options["app_name_map"] == {"ar.tvplayer.tv": "TiviMate"}
assert flow._options["app_icon_map"] == {"ar.tvplayer.tv": "http://i.local/t.png"}
@@ -510,7 +621,7 @@ class TestAppIconColumn:
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
await flow.async_step_init({"app_name_map": "ar.tvplayer.tv=TiviMate"})
await _options_submit(flow, {"app_name_map": "ar.tvplayer.tv=TiviMate"})
assert flow._options["app_name_map"] == {"ar.tvplayer.tv": "TiviMate"}
assert flow._options["app_icon_map"] == {} # blank-safe (no icons)
@@ -540,7 +651,7 @@ class TestAppIconColumn:
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
result = await flow.async_step_init({"app_name_map": "p=Name|has spaces"})
result = await _options_submit(flow, {"app_name_map": "p=Name|has spaces"})
assert result["type"] == "form"
assert result["errors"] == {"base": "invalid_app_map"}
@@ -562,11 +673,11 @@ class TestAppIconColumn:
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
result = await flow.async_step_init(
{"app_name_map": "com.blank=\ncom.netflix.ninja=Netflix"}
result = await _options_submit(
flow, {"app_name_map": "com.blank=\ncom.netflix.ninja=Netflix"}
)
assert result["type"] == "form"
assert result["type"] == "form" # the Apps sub-screen re-rendered
assert result["errors"] == {"base": "invalid_app_map"}
assert "app_name_map" not in flow._options # nothing half-stored
@@ -592,7 +703,7 @@ class TestChannelEntryOptions:
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
await flow.async_step_init({"channel_digit_delay": "0.3", "channel_send_enter": True})
await _options_submit(flow, {"channel_digit_delay": "0.3", "channel_send_enter": True})
assert flow._options["channel_digit_delay"] == 0.3
assert flow._options["channel_send_enter"] is True
@@ -603,7 +714,7 @@ class TestChannelEntryOptions:
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
result = await flow.async_step_init({"channel_digit_delay": "2.0"})
result = await _options_submit(flow, {"channel_digit_delay": "2.0"})
assert result["type"] == "form"
assert result["errors"] == {"base": "invalid_digit_delay"}
@@ -614,7 +725,7 @@ class TestChannelEntryOptions:
flow.async_show_form = lambda **kwargs: {"type": "form", **kwargs}
flow.async_create_entry = lambda **kwargs: {"type": "create_entry", **kwargs}
result = await flow.async_step_init({"channel_digit_delay": "slow"})
result = await _options_submit(flow, {"channel_digit_delay": "slow"})
assert result["type"] == "form"
assert result["errors"] == {"base": "invalid_digit_delay"}
@@ -1161,12 +1272,14 @@ class TestAppsMapOptionsEditor:
async def test_map_text_stored_as_dict(self, tmp_path):
flow = self._options_flow(tmp_path, options={})
result = await flow.async_step_init(
{"app_name_map": "ar.tvplayer.tv=TiviMate\ncom.plex.hdx=Plex"}
result = await _options_submit(
flow,
{"app_name_map": "ar.tvplayer.tv=TiviMate\ncom.plex.hdx=Plex"},
save=True,
)
assert result["type"] == "create_entry"
assert flow._options["app_name_map"] == {
assert result["data"]["app_name_map"] == {
"ar.tvplayer.tv": "TiviMate",
"com.plex.hdx": "Plex",
}
@@ -1174,7 +1287,7 @@ class TestAppsMapOptionsEditor:
async def test_invalid_map_shows_form_error(self, tmp_path):
flow = self._options_flow(tmp_path, options={})
result = await flow.async_step_init({"app_name_map": "no equals sign here"})
result = await _options_submit(flow, {"app_name_map": "no equals sign here"})
assert result["type"] == "form"
assert result["errors"] == {"base": "invalid_app_map"}
@@ -1184,10 +1297,10 @@ class TestAppsMapOptionsEditor:
tmp_path, options={"app_name_map": {"ar.tvplayer.tv": "TiviMate"}}
)
result = await flow.async_step_init({"app_name_map": " "})
result = await _options_submit(flow, {"app_name_map": " "}, save=True)
assert result["type"] == "create_entry"
assert flow._options["app_name_map"] == {}
assert result["data"]["app_name_map"] == {}
async def test_existing_map_prefills_the_text_field(self, tmp_path):
flow = self._options_flow(
@@ -1195,10 +1308,13 @@ class TestAppsMapOptionsEditor:
)
# first visit: the form carries the serialized current map as the
# field default
result = await flow.async_step_init(None)
# field default — NEVER the dict (that is the [object Object] bug)
result = await _options_open(flow, "apps")
assert result["type"] == "form"
fields = getattr(result["data_schema"], "schema", result["data_schema"])
suggested = fields.get("__suggested__", {})
assert suggested["app_name_map"] == "ar.tvplayer.tv=TiviMate" # t57
# the editor carried the existing map through the defaults path
assert flow._options["app_name_map"] == {"ar.tvplayer.tv": "TiviMate"}
@@ -1241,7 +1357,7 @@ class TestAppNameMapResolution:
player, _s, _i = _cmd_player(modules)
player._entry.options["app_name_map"] = {} # no override
manager = player._manager
manager._app_labels["ar.tvplayer.tv"] = ("TiviMate", manager._clock())
manager.cache_app_label("ar.tvplayer.tv", "TiviMate")
manager._on_current_app("ar.tvplayer.tv")
await asyncio.sleep(0.05)