t49's two residuals share one cause: `_app_directory` handed the raw options map to
its consumers, so the Browse tree showed a child with a blank title and the reverse
lookup compared the user's padded name against itself. The map is hand-editable, so
the normalization belongs in that one accessor — the browse tree, source_list and the
reverse lookup then cannot disagree.
Policy, now documented in `_app_directory` (the single place):
* names are stripped as the directory is built, so display and match key are one
string: `com.x= TiviMate ` shows as "TiviMate" AND resolves as "TiviMate";
* an entry whose NAME or whose PACKAGE is blank/whitespace-only is dropped, so no
surface offers a child with no title or no package behind it (the t48 invariant,
now extended to the Browse tree);
* two packages normalizing to the same name are BOTH KEPT — the tree reaches each by
its own package id, so neither app is hidden — while the FIRST in map order wins the
name: that is the single entry source_list offers and the package select_source
resolves. Map order is the user's order, so the winner does not move between
rebuilds; reversing the map reverses the winner, which is pinned.
* a well-formed map (what the editor and the curated default produce) is byte-identical
in and out: stripping touches the EDGES only, so "TiviMate Pro" keeps its inner space.
source_list now just dedupes the normalized values (its own t49 filter moved into the
accessor) and `_source_package` documents the same first-wins rule.
RED PROOFS, both residuals, against the previous tree (HEAD 9761540, swapped in and
restored): 5 failed / 20 passed before, 25 passed after. The pre-fix symptoms are
concrete — the Browse tree for a map with "" and " " entries and a blank package
returned titles ['', 'TiviMate', ' ', 'Ghost'] (an empty child, a whitespace child
and a child with no package), and a padded entry produced
source_list [' TiviMate ', 'Netflix'] with `_source_package("TiviMate")` returning
None (the resolution miss). After the fix both lists are ['TiviMate', 'Netflix'] and
the lookup answers. The well-formed-map test passes on BOTH trees, which is the
"unchanged for well-formed maps" half of the claim; surviving entries still launch
their key packages (protocol launch list asserted) and the curated fallback list is
untouched (its own test).
Deliberately NOT changed: `_app_name_map` (the raw map behind app_name/source and the
Current App sensor) still returns values as configured, so a padded entry can still
display padded in those two surfaces. A blank value already falls through the
resolve_app_name chain (`if mapped:`), so nothing is broken there, and stripping it
would change sensor.py's output — outside this repair's in-scope paths. Flagged in the
task report as a candidate follow-up rather than done quietly.
Verified: 463 passed / 2 skipped, 464 / 1 with androidtvremote2, ruff clean, drift
engine exit 0 under real HA (23 modules, 58 imports, 164 relative imports, 36 call
arguments, 7 awaited HA calls); the t47/t48 rig (sources, routing, command scripts,
both config-flow suites) is 259 passed.
t48 F1: source_list stringified the app map's values without filtering, so a
hand-edited map with a blank name ("com.x=") put an empty string in the dropdown.
That entry is worse than useless — it is a dropdown item whose only possible outcome
is an error, because select_source has to reject an empty name as an unknown source.
source_list now drops blank and whitespace-only names. The values themselves are
untouched: no name is rewritten or trimmed here, because the reverse lookup matches on
them, so for a well-formed map the list stays byte-equal to the Browse tree's child
titles (same _app_directory accessor, same order) — pinned by comparing the two
directly, for the user's map and for the curated default. The reverse lookup is
unchanged.
Tests (tests/test_media_sources.py, 3 new; tests/test_standalone_setup.py, 1 new):
a hand-edited map with "" and " " entries offers only the real names; the offered
list equals the Browse tree titles for well-formed maps (both map and curated
default); the surviving entries still resolve AND launch through select_source, and a
blank name still resolves to nothing; and the editor keeps rejecting "com.x="
(_app_map_from_text returns None, the page answers invalid_app_map and stores nothing
half-way), so a blank name cannot get in that way either.
RED PROOF: with the filter removed, exactly the two blank-name tests fail — the blank
appears in the list, and the entry has no package to resolve to (1 passed / 2 failed;
the byte-equality test passes either way, which is the "well-formed list unchanged"
half of the claim) — while the rest of the file is green; restored, 20 pass.
Verified: 458 passed / 2 skipped, 459 / 1 with androidtvremote2, ruff clean, drift
engine exit 0 under real HA (23 modules, 58 imports, 164 relative imports, 36 call
arguments, 7 awaited HA calls).
Residual, deliberately NOT touched here (out of this repair's scope, and pre-existing):
a blank map entry still reaches the Browse tree as an empty child title, and a PADDED
name (" TiviMate ", hand-edited only — the editor strips) still fails the reverse
lookup because the map value is matched unstripped. Both are hand-edited-map edges;
say the word and either can be a follow-up.
One optional 'Remote command script' on the Configure page. Configured, it replaces
the whole key path for remote.send_command; unset, repeats, long presses and delays go
out as protocol keys exactly as before (the existing send_command tests pass
untouched).
A remote command is a SEQUENCE — repeats, a START_LONG/END_LONG pair, an inter-repeat
delay — so the escape hatch is one script for the call rather than per-key overrides:
an IR bridge, a macro, or a second device needs the call, not individual keys. The
script receives the service's own parameters as variables, named as they are on the
service and documented in the README and pinned in tests:
command the list of key commands as submitted (e.g. ["HOME", "KEYCODE_DPAD_CENTER"])
num_repeats the repeat count (default 1)
delay_secs the inter-repeat delay (default 0.0)
hold_secs the long-press duration, START_LONG/END_LONG handled by the script
(default 0.0) — the script owns the key semantics, so it decides what
"hold" means
All four are always present, including defaults, so a script can rely on the shape.
A raising script surfaces a translated error rather than a silent no-op. That made the
t44 script-failure mechanism consistent in the process: async_call_script() is now the
one caller for both the media player's per-command scripts and the remote's, raising
ServiceValidationError(translation_domain="nvidia_shield",
translation_key="command_script_failed") with {action, script, error} placeholders
instead of a bare HomeAssistantError — the t44 test was updated to assert the
translation contract (domain, key, placeholders) rather than a message string, which is
stronger evidence and gives the user real copy. The new copy lives in BOTH strings
files, as does the unknown_source key from UNIT A.
Option handling reuses the t47 C work: the field is an entity selector (so a cleared
value is tolerated by the schema), the stored value is trimmed, and a blank,
hand-edited or non-string value means "not configured" rather than a call to nowhere.
Verified: 454 passed / 2 skipped, ruff clean, drift engine exit 0 under real HA
(23 modules, 58 imports, 164 relative imports, 36 call arguments, 7 awaited HA calls).
The last piece a universal-media-player replacement needs: a dropdown of the apps,
an answer to what is on screen, and a way to pick one — all reading ONE app directory
(the user's Apps map, else DEFAULT_APP_DIRECTORY), so the dropdown can never offer a
name the launch path cannot resolve. That invariant is pinned by a test that walks
source_list and resolves every entry.
* `source_list` — friendly names in directory order (the user's map order, or the
curated list), from a new `_app_directory` accessor that the browse tree now shares.
* `source` — the foreground app's resolved name via the existing `resolve_app_name`
chain (map → cached device-manifest label → raw package), i.e. exactly `app_name`,
so the media player and the Current App sensor cannot disagree.
* `select_source(name)` — a configured select_source script still takes precedence
(t44, unchanged: selecting a source can be more than launching an app); without one
the name is resolved back to its package and launched over the shared
`play_media("app", …)` path, so the native semantics are the ones play_media already
had. Accepts a raw package id (what the browse tree publishes) and matches names
case-insensitively; an unknown or empty name raises
ServiceValidationError(translation_domain="nvidia_shield",
translation_key="unknown_source", translation_placeholders={"source": …}) — a
translated error, never a silent no-op — with new copy in BOTH strings files.
* SELECT_SOURCE is advertised when EITHER a select_source script is configured OR
source_list is non-empty; VOLUME_SET stays script-only (the protocol has no
absolute-volume key), and the two flags are pinned as independent.
Two t44 tests were updated because this is a deliberate behaviour change: "no native
path" is now only true of volume_set, and SELECT_SOURCE is advertised by default
because the curated directory always offers something.
Also: rewriting the strings files re-indented the seven script labels added in t44
(they were valid JSON but sat two levels shallow); the diff there is that fix plus
the new key.
Verified: 441 passed / 2 skipped, ruff clean, drift engine exit 0 under real HA
(23 modules, 59 imports, 157 relative imports, 36 call arguments, 7 awaited HA calls).
The user's remaining universal-media-player dependency: some commands need hardware
this integration deliberately does not drive — a speaker switch that owns the
volume, an AVR input that has to move with the Shield, a mute that has to hit two
devices. One script per command is the escape hatch, and the rule is absolute: a
configured command calls its script INSTEAD of the native protocol/ADB path, and
with nothing configured every command behaves exactly as it did (the whole existing
suite passes untouched, which is what "byte-identical" means here).
* const.py owns the option: CONF_COMMAND_SCRIPTS (one dict, keyed by command),
SCRIPT_COMMANDS (turn_on, turn_off, volume_up, volume_down, volume_set,
volume_mute, select_source) and command_scripts(), the ONE normalizer of the
stored shape — options round-trip through HA storage and can be hand-edited, so a
value that is not a non-empty string for a known command is dropped instead of
reaching the service layer.
* media_player.py: async_run_command_script() calls script.turn_on with the script
entity and its variables, blocking=True so a failure surfaces HERE — a mapped
command that cannot run raises HomeAssistantError naming the command and script
rather than falling back silently or doing nothing. Every command method checks
its script first; volume_set passes `level` and select_source passes `app_name`.
* volume_mute also passes `is_volume_muted`: without it a mute script cannot tell
muting from unmuting, which is the case the user asked for.
* volume_set and select_source have NO native path (C-VOL; app launching goes
through play_media), so they are advertised only when configured — the feature
flag is what makes HA offer the command at all. Unset leaves the feature set
exactly as it was.
* remote.py shares async_run_command_script for POWER: turn_on/turn_off is one
configuration and the remote entity is the other route to it, so a script that
owns the power sequence owns it on both.
* config_flow.py: one entity selector per command (domain: script), stored as the
single dict option, prefilled from the entry's options and cleared by saving
without a value — the editor is the truth, as with the app map editor.
Two things the harness caught, worth recording: `MediaPlayerEntityFeature` has no
SET_VOLUME (the real member is VOLUME_SET — the stub enum and the drift engine
disagree with a wrong name), and the stub `homeassistant.exceptions` had no
HomeAssistantError, so the stub now mirrors the real inheritance
(ServiceValidationError derives from it).
tests/test_command_scripts.py covers each command mapped and unmapped, the exact
variable payloads, the native path untouched when unmapped (protocol key, then the
ADB fallthrough with the protocol down), the rejecting script's error, hand-edited
option shapes, and the feature set. test_config_flow_ux.py covers the selectors,
the stored shape, prefill and clearing. New tests leaked the harness into the next
file at first (a missing per-file scope); that is fixed, and
tests/test_harness_scoping.py now asserts that every file loading the harness scopes
it, so the next such mistake fails where it is made.
Verified: 401 passed / 2 skipped, 402 / 1 with androidtvremote2, reverse file order
401 / 2, ruff clean, and the drift engine exits 0 on a real-HA interpreter
(23 modules, 59 imports, 157 relative imports, 36 call arguments, 6 awaited HA calls).
media_player.py's module docstring still described the command path as
"protocol key push → ADB keyevent → external androidtv_remote", calling the last
tier "transitional; removed in Phase 4". t41 removed it, so the sentence
documented a path that no longer exists; it now names the two real tiers.
tests/test_module_coverage.py recorded the sweeps' red proof as the pre-t41 commit.
That commit cannot prove either sweep: at c588a7c ``helpers.py`` still DEFINED
``discover_adb_host`` (line 45, and button.py imported it successfully), so there
was no broken relative import to find — and that commit's ``ha_api_drift.py``
predates BOTH passes (no entity-contract check, no package-import check), so a
real-HA run there is clean: verified just now, exit 0, "no Home-Assistant API
drift (13 modules, 60 imports, 35 call arguments, 7 awaited HA calls)". The
docstring now says the proof is built rather than borrowed, which is what the two
red-proof tests actually do (a copy of the tree with the reference reverted).
Verified: 362 passed / 2 skipped, ruff clean.
The user ruling: this integration REPLACES both old integrations, so nothing may
depend on an androidtv/androidtv_remote entity anymore.
Removed:
* the setup-form field + its entity selector (CONF_ANDROIDTV_ENTITY is no longer
offered, only read by the migration that drops it);
* the entity-derived unique_id derivation (_unique_id_from_atv_device, t29 G2)
and, with it, every registry lookup for another integration's devices — the
user step now sets NO unique_id at all; the protocol MAC learned during
pairing is the identity;
* the Jellyfin auto-detect's second tier (the atv entity's friendly_name) — it
now uses the entry name the user typed or the device name from discovery;
* the app-name and app-id tiers (media_player.app_name/app_id + the shared
resolve_app_name chain, the Current App sensor, the sensor's state-change
listener and its `_androidtv_state` helper) — the manager's store is the only
source;
* the "transitional external entity" command tier in every path: `_cmd` and the
pan-media_player service call are gone, and so is the fallback in _media_cmd,
_remote_key_cmd, the §7 channel-digit sequence and the intent self-heal retry
(an intent no transport served now surrenders to the truth instead of
delegating);
* the entity-derived power signal (`_on_source_change`'s "device off clears the
intent" check no longer watches another integration's entity; the power
LADDER — protocol is_on → ADB wake → power switch — is unchanged).
turn_on/turn_off are native now: they send the protocol POWER key and, like the
remote entity, send nothing when the known state already matches (no superfluous
toggle). An ADB-only entry (protocol skipped) has no power write path — the
manager owns the socket and exposes no device-keyevent, which is what the old
delegation used to cover.
helpers.discover_adb_host — the one helper that derived a host from another
integration's config entry — is deleted; button.py (granted to this task) reads
the entry's own address fields instead of the entity, so the Pair ADB button
keeps working.
Present-day entries that still carry the key are unaffected (nothing reads it);
the v3 migration that drops it lands with the host unification.
Verified: 309 passed / 2 skipped, 310 / 1 with androidtvremote2, ruff clean,
drift engine exit 0 on HEAD (13 modules, 58 imports, 35 call arguments, 6
awaited HA calls) and 16 passed in the real-HA drift suite. The stub harness's
legacy service recorder became a watchdog: any HA service call from the media
player now fails the routing tests instead of being the expected outcome.
Reported from a live install: with ADB enabled but unreachable and a paired,
healthy protocol transport, the media player went unavailable even though the
protocol still served power/app/volume.
The old rule was ``manager.connected or manager.awake is not None or not
manager.available`` — i.e. "a configured ADB manager that is down ⇒ the whole
entity is down" (t5-F2), which ignored transport B entirely. The ladder is now:
* protocol transport wired and not reported unreachable — usable;
* or ADB connected — usable;
* or fresh wake data (the poller has spoken) — usable;
* or a passive manager (no ADB configured at all) — usable, because nothing to
transport is not a broken transport.
Unavailable only when a CONFIGURED transport is actively down and no usable data
exists. One implementation, ``entity_available`` (media_player.py), is used by the
media player, the remote (whose ladder previously duplicated a weaker version of
this rule) and the diagnostics payload.
Diagnostics grew ``transport.entity_available`` plus ``adb.configured`` so a
support dump answers "why is my entity unavailable?" — the ladder inputs and its
verdict are now in the same place.
Pinned by tests: the exact user case (ADB configured+down, protocol up ⇒
available), all-down (⇒ unavailable), wake-data rescue, connected ADB without a
protocol, the passive manager, a configured-but-down transport with no data, the
remote following the same ladder, and the diagnostics verdict.
Verified: 310 passed / 2 skipped, 311 passed / 1 skipped (androidtvremote2),
16 passed in the HA drift suite, ruff clean.
Reported from a live install: the sensor showed ``ar.tvplayer.tv`` while the
media player showed "TiviMate" — the same foreground app, two identities.
The sensor's ``native_value`` returned ``manager.foreground_app`` (the raw
package push) or ``running_apps[0]["package"]`` as-is, while the media player
resolved names through apps map → cached manifest label → androidtv entity tier.
Both now call ONE function, ``resolve_app_name`` (media_player.py), so the two
entities cannot disagree again; the sensor keeps the raw package as its
``app_id`` attribute and ``app_name`` carries the shown name.
Per the contract:
* apps map wins, then the cached manifest label, then the androidtv entity's
``app_name``, then the package text itself;
* a bare or unknown package falls back to the package text, NOT None (it used to
be possible for the value to vanish while the app was plainly running);
* pinned by tests: map tier, label tier, entity tier, the ADB-recents source, the
unknown-package fallback, and sensor == media player for the same inputs.
Verified: 302 passed / 2 skipped, ruff clean.
Reported from a live install: the entity never rendered, and every state write
raised ``TypeError: 'method' object is not iterable``.
Root cause, from HA's own code (homeassistant/helpers/entity.py,
``__async_calculate_state``):
if extra_state_attributes := self.extra_state_attributes:
attr |= extra_state_attributes
``attr`` is a dict, so ``|=`` needs a mapping — and this was declared as a plain
method while the sensors' were properties. Reproduced against the pre-fix tree
with real HA: instantiating the entity and calling ``async_write_ha_state()``
prints ``REPRODUCED TypeError: 'method' object is not iterable``; with the fix
the same call writes the state and the extra attributes through.
The stub suites could never see it: their harness replaces
``async_write_ha_state``, so the method was simply called and returned a dict.
tests/test_ha_api_drift.py now drives HA's real state calculation for every
entity, and the drift sweep grew an entity-contract pass (next commit).
`homeassistant.components.media_player` has not exported `BrowseMediaSource`
for years: the class lives in `homeassistant.components.media_source.models`,
where it exists to build `media_source://<domain>` ids and is the only place a
`domain` parameter belongs (t30 G18's note was about THAT class). The local
import therefore raised ImportError, and with it every real browse failed —
while the suite stayed green because the harness stubbed a `BrowseMediaSource`
that real HA does not expose from there.
`async_browse_media` now builds the standard tree exactly as core's own
androidtv_remote/media_player.py does: `BrowseMedia` imported from
homeassistant.components.media_player at module scope, every node carrying an
explicit media_content_id/media_content_type (MediaType.APP/APPS),
`children_media_class=MediaClass.APP` on the directory, and no `domain`.
Verified against a real install: the old import raises ImportError, the old
`domain=` kwarg raises TypeError, and the new node constructs.
The harness is now faithful so this cannot hide again:
* `entity_stublib` exposes a `BrowseMedia` with the real keyword-only
signature, `as_dict()` and `calculate_children_class()`, plus `MediaType`,
and deliberately NOT `BrowseMediaSource` — so both the drifted import and the
drifted kwarg fail in-suite;
* the app-directory test asserts the standard shape (classes, explicit ids and
types, children_media_class, the serialised payload) instead of the removed
`domain` attribute;
* TestFaithfulHaStubs pins the harness contract on the AST (no production
module imports BrowseMediaSource) rather than on prose.
Verified: 284 passed, 2 skipped (D1 stage); ruff clean. Full gate re-run on the
completed branch: 289 passed / 2 skipped and 290 passed / 1 skipped.
So ha-media-zone's card can interpolate the playhead smoothly between
server polls instead of snapping once every 10-30s.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Home Assistant custom integration for NVIDIA Shield TV that composes
the Android TV Remote, Jellyfin, smart plug, and receiver entities
into a single unified media player with ADB-based now-playing from
any app. Part of the Media Zone ecosystem.
Entities:
- media_player: power (with smart plug fallback), transport, input,
volume routed to receiver, rich stream info overlay when Jellyfin
is active
- sensor: DAG attributes and stream info
- button: Pair ADB
Config:
- Two-step flow: basic setup + optional ADB pairing on the Shield
- Configurable ADB port (default 5555)
- Pick an existing Jellyfin media_player entity — stream info is read
from the HA Jellyfin integration's own DataUpdateCoordinator, so
no server URL / API key / extra config needed here
- Privacy mode (adb_store_app_info=False) suppresses non-Jellyfin
media details, showing only the app name
Ecosystem:
- Exposes media_dag_* attributes for ha-media-zone orchestration
- Exposes media_dag_running_apps and async_bring_to_foreground for
the Media Zone card's app switcher
Shared ADB code lives in ltomes/ha-adb-common and is vendored at
custom_components/nvidia_shield/ha_adb_common/ for HACS installs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>