The user's live run could not select his Shield for
nvidia_shield.adb_command, so he ran his diagnostics through the deprecated
androidtv integration instead. Measured against the installed HA (2026.10.1),
the cause is a contract, not a bug in the handler:
* ServiceRegistry.async_register has NO target parameter, so a target cannot be
declared at registration;
* the frontend renders the target picker from the service DESCRIPTION, which
HA builds from services.yaml or from the runtime async_set_service_schema —
and the runtime description WINS.
So async_setup now registers with supports_response=OPTIONAL and publishes
ADB_COMMAND_SERVICE_DESCRIPTION (target: {entity: [{domain: media_player}]},
fields, prose mirrored in strings.json/translations/en.json and pinned against
both by a test). No services.yaml is needed, and none is added.
The target resolution is strict, because the failure mode of guessing is a raw
shell command on the wrong device: exactly one PARTICIPANT, told apart as
no_target (nothing selected), unsupported_target (not a participant / option
off / another integration's entity) and ambiguous_target (two selected — sent
to neither). Accepts what HA actually sends: entity_id as text or as the LIST a
picked target produces, and device_id (an automation targeting the DEVICE),
expanded only through this integration's own registry entries and only to media
players. The output now comes back in the response under adb_response — the key
the user's captures read — while an unreachable Shield stays adb_unavailable,
an error rather than an empty response, and a call that does not ask for a
response is unchanged.
Evidence: 12 real-HA tests (new tests/test_adb_command_real_ha.py) drive the
real service registry, the real get_services websocket payload (the exact JSON
bytes the browser parses: target present, response optional, both fields) and
real async_call for every shape above. t14's CI gate globs tests/test_*real_ha.py
so a new real-HA file cannot skip silently; the skip/empty-collection guards stay
and were executed. The real-HA probe also caught async_entries_for_device being
a MODULE-LEVEL helper (not a registry method) that the stub had hidden, so the
drift gate's owner map now covers async_register, async_set_service_schema and
async_entries_for_device, with the call-argument floor raised 44 -> 52.
Verify: suites 681/4 and 682/3; real-HA 38 + the new file 12 (50 with the glob);
ruff clean; drift exit 0 (54 call arguments).
386 lines
17 KiB
Python
386 lines
17 KiB
Python
"""Constants for the Nvidia Shield integration."""
|
|
|
|
# Re-export shared Media DAG constants
|
|
from .ha_adb_common.const import ( # noqa: F401
|
|
ATTR_MEDIA_DAG_JELLYFIN_ENTITY,
|
|
ATTR_MEDIA_DAG_RECEIVER_ENTITY,
|
|
ATTR_MEDIA_DAG_RECEIVER_INPUT,
|
|
ATTR_MEDIA_DAG_ROLE,
|
|
ATTR_MEDIA_DAG_RUNNING_APPS,
|
|
)
|
|
|
|
DOMAIN = "nvidia_shield"
|
|
|
|
# t41: the INTEGRATION's address field — one Shield IP for the whole stack
|
|
# (protocol + ADB). The two keys below it are LEGACY: a YAML seed may still
|
|
# carry them and the v3 entry migration maps them onto CONF_HOST, but no
|
|
# runtime code reads them.
|
|
CONF_HOST = "host"
|
|
CONF_ADB_HOST = "adb_host"
|
|
CONF_PROTOCOL_HOST = "protocol_host"
|
|
|
|
# t41 legacy: dropped from entries by the v3 migration (the androidtv entity
|
|
# path it configured is gone), never read at runtime.
|
|
CONF_ANDROIDTV_ENTITY = "androidtv_entity"
|
|
CONF_JELLYFIN_ENTITY = "jellyfin_entity"
|
|
CONF_RECEIVER_ENTITY = "receiver_entity"
|
|
CONF_RECEIVER_INPUT = "receiver_input"
|
|
CONF_POWER_ENTITY = "power_entity"
|
|
CONF_ADB_ENABLED = "adb_enabled"
|
|
CONF_ADB_STORE_APP_INFO = "adb_store_app_info"
|
|
CONF_ADB_PORT = "adb_port"
|
|
|
|
# Remote-protocol transport (androidtvremote2) — Phase 2
|
|
CONF_PROTOCOL_ENABLED = "protocol_enabled"
|
|
CONF_PAIRING_CODE = "pairing_code"
|
|
# t33 F-B: the "continue without pairing" escape offered on BOTH pairing
|
|
# steps — the entry still finishes (passive mode) instead of stranding the
|
|
# user on a form with no way forward.
|
|
CONF_SKIP_PAIRING = "skip_pairing"
|
|
DEFAULT_PROTOCOL_API_PORT = 6466
|
|
DEFAULT_PROTOCOL_PAIR_PORT = 6467
|
|
|
|
ADB_POLL_INTERVAL = 10 # seconds
|
|
ADB_RETRY_INTERVAL = 300 # seconds before retrying a failed ADB connection
|
|
DEFAULT_ADB_PORT = 5555
|
|
|
|
# Persistent-connection manager timing (Phase 1, docs/MVP_PLAN.md §2):
|
|
# watchdog reconnects with exponential backoff + jitter; ADB-derived store
|
|
# fields expire (B5) instead of clinging to stale values.
|
|
ADB_RECONNECT_BACKOFF_START = 5 # seconds before the first reconnect retry
|
|
ADB_RECONNECT_BACKOFF_CAP = 300 # seconds; backoff never sleeps longer
|
|
ADB_RECONNECT_JITTER = 0.25 # ±25 % randomization against reconnect storms
|
|
STATE_STORE_TTL = 60 # ADB-sourced fields older than this are stale
|
|
|
|
DEFAULT_NAME = "Nvidia Shield"
|
|
|
|
# Config-entry schema version (t41): v1 → v2 added the remote-protocol fields,
|
|
# v2 → v3 collapsed the two address fields into CONF_HOST and dropped the
|
|
# androidtv-entity key. One constant so the flow and the migration cannot drift.
|
|
ENTRY_VERSION = 3
|
|
|
|
# Emotiva DAG attribute we read to get available inputs
|
|
ATTR_MEDIA_DAG_INPUT_MAP = "media_dag_input_map"
|
|
|
|
# Known Jellyfin app package IDs (used to route Jellyfin-family clients
|
|
# — including Wholphin, a Jellyfin client — to the rich-metadata path).
|
|
JELLYFIN_APP_IDS = [
|
|
"org.jellyfin.androidtv",
|
|
"org.jellyfin.mobile",
|
|
"com.github.damontecres.wholphin",
|
|
]
|
|
|
|
# Remote-protocol key names (RemoteKeyCode enum in remotemessage.proto).
|
|
# HA service kind → protocol keycode string.
|
|
PROTOCOL_MEDIA_KEYS = {
|
|
"media_play": "KEYCODE_MEDIA_PLAY",
|
|
"media_pause": "KEYCODE_MEDIA_PAUSE",
|
|
"media_stop": "KEYCODE_MEDIA_STOP",
|
|
"media_next_track": "KEYCODE_MEDIA_NEXT",
|
|
"media_previous_track": "KEYCODE_MEDIA_PREVIOUS",
|
|
}
|
|
PROTOCOL_VOLUME_KEYS = {
|
|
"volume_up": "KEYCODE_VOLUME_UP",
|
|
"volume_down": "KEYCODE_VOLUME_DOWN",
|
|
"volume_mute": "KEYCODE_VOLUME_MUTE",
|
|
}
|
|
PROTOCOL_REMOTE_KEYS = {
|
|
**PROTOCOL_VOLUME_KEYS,
|
|
"play": "KEYCODE_MEDIA_PLAY",
|
|
"pause": "KEYCODE_MEDIA_PAUSE",
|
|
"stop": "KEYCODE_MEDIA_STOP",
|
|
"up": "KEYCODE_DPAD_UP",
|
|
"down": "KEYCODE_DPAD_DOWN",
|
|
"left": "KEYCODE_DPAD_LEFT",
|
|
"right": "KEYCODE_DPAD_RIGHT",
|
|
"enter": "KEYCODE_DPAD_CENTER",
|
|
"home": "KEYCODE_HOME",
|
|
"back": "KEYCODE_BACK",
|
|
"menu": "KEYCODE_MENU",
|
|
"search": "KEYCODE_SEARCH",
|
|
"power": "KEYCODE_POWER",
|
|
"channel_up": "KEYCODE_CHANNEL_UP",
|
|
"channel_down": "KEYCODE_CHANNEL_DOWN",
|
|
}
|
|
|
|
# §7 channel selection: digits only, ~0.1 s apart, no ENTER by default
|
|
# (replicates HA core androidtv_remote's verified behavior).
|
|
# Zeroconf discovery (Phase 4): the Android TV remote service type.
|
|
ZEROCONF_SERVICE_TYPE = "_androidtvremote2._tcp.local."
|
|
|
|
# P4-1 apps name/icon map (options flow editor): package → friendly name,
|
|
# consumed by app_name after the androidtv-entity and protocol-push tiers.
|
|
CONF_APP_NAME_MAP = "app_name_map"
|
|
|
|
# P5-2: optional icon column (package|icon) — package → icon URL, feeding
|
|
# entity_picture when no Jellyfin session picture is live.
|
|
CONF_APP_ICON_MAP = "app_icon_map"
|
|
|
|
# P5-1: device manifest label cache TTL (long — labels change with app
|
|
# updates, not per poll).
|
|
APP_LABEL_TTL = 24 * 3600
|
|
|
|
# t57 UNIT 0: exit marker appended to the label dump so a FAILED fetch can be
|
|
# told from a package that genuinely has no label. ``adb shell`` output ending
|
|
# in this marker means the command ran; an empty answer still carries it, and
|
|
# a transport that never delivered output does not. The value is arbitrary but
|
|
# unlikely to collide with a manifest line.
|
|
APP_LABEL_EXIT_MARKER = "__NSH_EXIT:"
|
|
|
|
# t66: the label candidates searched FOR ON THE DEVICE. A real `pm dump` on the
|
|
# user's Shield is ~2241 lines of dumpsys (package + activity + meminfo +
|
|
# procstats + usagestats + batterystats), and the old fetch shipped
|
|
# ``| head -300`` — which cut before the package sections even appeared, so the
|
|
# negative was a truncation artefact rather than an answer. Searching on-device
|
|
# keeps the wire small AND the answer honest: these are the three shapes Android
|
|
# prints for an app's label (see ha_adb_common/app_label.py for what counts as a
|
|
# name in each).
|
|
APP_LABEL_GREP_SHAPES = "application-label|nonLocalizedLabel|labelRes"
|
|
|
|
# t66: how many label probes may come back "ran, no label" IN A ROW before the
|
|
# device is treated as one that exposes no labels at all (this user's Shield).
|
|
# One label-less package is ordinary (system/OEM packages often have none), so a
|
|
# single miss proves nothing — but a run of them means the whole firmware keeps
|
|
# its labels in resources we cannot read, and every further ``pm dump`` per
|
|
# package is a shell spent on a question already answered. The conclusion expires
|
|
# with APP_LABEL_TTL so a device change is noticed within a day, and any label
|
|
# found anywhere resets the run.
|
|
APP_LABEL_PROBE_LIMIT = 5
|
|
|
|
# P4-2a adb_command service: raw ADB shell on this entry — OFF by default
|
|
# (same security posture as the core androidtv integration).
|
|
CONF_ADB_COMMAND_ENABLED = "adb_command_enabled"
|
|
SERVICE_ADB_COMMAND = "adb_command"
|
|
|
|
#: t14: the key the command's output is returned under, in the service RESPONSE.
|
|
#: It matches the deprecated ``androidtv.adb_command`` the user fell back to, so a
|
|
#: template written against that integration keeps reading the same field.
|
|
ADB_COMMAND_RESPONSE_KEY = "adb_response"
|
|
|
|
#: t14: the picker the UI offers for `nvidia_shield.adb_command`.
|
|
#:
|
|
#: MEASURED on HA 2026.10.1: ``ServiceRegistry.async_register`` has NO ``target``
|
|
#: parameter, so a target cannot be declared at registration. The frontend renders
|
|
#: the target picker from the service DESCRIPTION instead (``services.yaml``, or a
|
|
#: runtime ``async_set_service_schema`` call, which WINS over services.yaml) — this
|
|
#: is that description's ``target``. Only the ENTITY kind is declared: the Shield is
|
|
#: selected as its ``media_player`` entity (one per entry), exactly like the
|
|
#: deprecated ``androidtv.adb_command`` the user used to fall back to.
|
|
#:
|
|
#: The entity domain the action's target declares, and the only domain a
|
|
#: ``device_id`` expands to (a Shield device also carries sensors, a remote, an
|
|
#: image entity — none of those is something to run a raw shell command on).
|
|
ADB_COMMAND_TARGET_DOMAIN = "media_player"
|
|
|
|
ADB_COMMAND_SERVICE_TARGET: dict = {
|
|
"entity": [{"domain": ADB_COMMAND_TARGET_DOMAIN}]
|
|
}
|
|
|
|
#: t14: the service DESCRIPTION registered at setup (``async_set_service_schema``).
|
|
#:
|
|
#: Why a runtime description at all: the description is what the frontend receives
|
|
#: from ``get_services``, and a service with no description has no ``target``, which
|
|
#: is precisely why the user's UI offered no Shield to select and he had to run his
|
|
#: diagnostics through the deprecated integration. The prose here mirrors
|
|
#: ``strings.json``/``translations/en.json`` (where HA's translations live) so the
|
|
#: form renders even before a translation is loaded; ``tests/test_adb_command_target.py``
|
|
#: asserts the two cannot drift.
|
|
ADB_COMMAND_SERVICE_DESCRIPTION: dict = {
|
|
"name": "ADB command",
|
|
"description": (
|
|
"Send a raw ADB shell command to the persistent connection of an Nvidia "
|
|
"Shield entry (option-gated use)."
|
|
),
|
|
"target": ADB_COMMAND_SERVICE_TARGET,
|
|
"fields": {
|
|
"command": {
|
|
"name": "Command",
|
|
"description": "The shell command to run on the device.",
|
|
"required": True,
|
|
"selector": {"text": {}},
|
|
},
|
|
"entity_id": {
|
|
"name": "Entity",
|
|
"description": (
|
|
"The Shield to run it on: its media_player entity. Select it in "
|
|
"the target picker above, or pass entity_id (or the Shield's "
|
|
"device_id) in YAML."
|
|
),
|
|
"selector": {"entity": {"domain": "media_player"}},
|
|
},
|
|
},
|
|
}
|
|
|
|
# P5-3 (C-STREAM): live events push — the streaming_shell logcat consumer
|
|
# (foreground AppState fast-path). Default OFF; the user flips it on and
|
|
# reports stability on their device.
|
|
CONF_LOGCAT_EVENTS = "logcat_events"
|
|
|
|
# P5-3: duplicate START for the same package inside this window is not
|
|
# re-stamped (repeat suppression).
|
|
LOGCAT_SUPPRESS_WINDOW = 15.0
|
|
|
|
# P4-2c/t66: the curated package -> name table. It is BOTH the BrowseMedia/source
|
|
# directory fallback and the third tier of the app-name chain (after the user's
|
|
# own map and a device label, before the raw package id) — on a Shield whose
|
|
# `pm dump` carries no label at all it is the answer that makes names work.
|
|
#
|
|
# It is a community-maintained convenience, NOT device truth: package ids are
|
|
# stable, display names change with vendors and app updates, and an app this
|
|
# table has never heard of falls back to its package id (shown once). Users
|
|
# override any entry in Configure > Apps, and that edit always wins. Adding a row
|
|
# is deliberately trivial — keep the ids sorted so the diff stays reviewable.
|
|
DEFAULT_APP_DIRECTORY = {
|
|
"ar.tvplayer.tv": "TiviMate",
|
|
"be.digitalia.mediasession2mqtt": "MediaSession2MQTT",
|
|
"com.amazon.amazonvideo.livingroom": "Prime Video",
|
|
"com.esaba.downloader": "Downloader",
|
|
"com.github.damontecres.wholphin": "Wholphin",
|
|
"com.google.android.sambadocumentsprovider": "Samba (SMB)",
|
|
"com.google.android.speech.pumpkin": "Google Speech Services",
|
|
"com.google.android.tts": "Speech Services by Google",
|
|
"com.google.android.tvlauncher": "Google TV launcher",
|
|
"com.google.android.youtube.tv": "YouTube",
|
|
"com.hyperion.grabber": "Hyperion Grabber",
|
|
"com.lonelycatgames.Xplore": "X-plore File Manager",
|
|
"com.nendo.argosy": "Argosy",
|
|
"com.netflix.ninja": "Netflix",
|
|
"com.plexapp.mediaserver.smb": "Plex Media Server",
|
|
"com.retroarch": "RetroArch",
|
|
"com.spocky.projengmenu": "Projectivy Launcher",
|
|
"dev.jdtech.jellyfin": "Findroid",
|
|
"dev.trooped.tvquickbars": "TV Quick Actions",
|
|
"flar2.homebutton": "Button Mapper",
|
|
"tv.pluto.android": "Pluto TV",
|
|
}
|
|
|
|
# t57: best-effort icon per discovered app. `pm dump` does NOT expose the
|
|
# app's icon resource (an extracted APK's icon is a bitmap only its own code
|
|
# can resolve, and `dumpsys package` prints no drawable reference), so a device
|
|
# icon is not obtainable over plain shell. These MDI names are therefore a
|
|
# HEURISTIC: a curated package -> MDI mapping with a generic fallback, honest
|
|
# about being a guess. The user's own `app_icon_map` (a picture URL or camera
|
|
# entity, see CONF_APP_ICON_MAP) always outranks it for entity_picture.
|
|
APP_ICON_FALLBACKS = {
|
|
"ar.tvplayer.tv": "mdi:television-classic",
|
|
"com.github.damontecres.wholphin": "mdi:jellyfish",
|
|
"com.netflix.ninja": "mdi:netflix",
|
|
"com.google.android.youtube.tv": "mdi:youtube-tv",
|
|
"com.google.android.tvlauncher": "mdi:view-dashboard",
|
|
"org.xbmc.kodi": "mdi:kodi",
|
|
"com.plexapp.android": "mdi:plex",
|
|
"com.amazon.amazonvideo.livingroom": "mdi:amazon",
|
|
"com.disney.disneyplus": "mdi:movie-open",
|
|
"com.spotify.tv.android": "mdi:spotify",
|
|
"com.google.android.apps.youtube.music": "mdi:music",
|
|
"com.valvesoftware.steamlink": "mdi:gamepad-variant",
|
|
"com.spotify.music": "mdi:spotify",
|
|
}
|
|
#: The fallback for an app this map has never heard of.
|
|
APP_ICON_GENERIC = "mdi:television"
|
|
|
|
# t57: installed-app discovery. The package list changes only when the user
|
|
# installs or uninstalls something, so a long TTL is right; refreshing is
|
|
# explicit (the options page and the app-list sensor ask for it) rather than a
|
|
# per-poll cost.
|
|
APP_LIST_TTL = 6 * 3600
|
|
#: `pm dump` sweeps for labels run in a small burst — enough to finish a
|
|
#: typical Shield quickly, few enough that the label fetches never queue
|
|
#: behind each other on one socket or stall the watchdog.
|
|
APP_SWEEP_CONCURRENCY = 4
|
|
#: Spacing between the fetches of one sweep, per worker. Keeps the burst a
|
|
#: trickle on the one socket the watchdog uses, while ~40 apps still finish in
|
|
#: a few seconds instead of a few minutes.
|
|
APP_SWEEP_SPACING = 0.05
|
|
|
|
CHANNEL_DIGIT_DELAY = 0.1
|
|
CONF_CHANNEL_DIGIT_DELAY = "channel_digit_delay"
|
|
CONF_CHANNEL_SEND_ENTER = "channel_send_enter"
|
|
|
|
# Verified on-device package names (docs/RESEARCH_SHIELD.md): TiviMate does
|
|
# not publish a MediaSession on Shield Android 11 — it is the headline
|
|
# Phase-3 fallback app.
|
|
TIVIMATE_PACKAGE = "ar.tvplayer.tv"
|
|
|
|
# Packages to ignore when parsing foreground/audio-playback identities
|
|
SYSTEM_MEDIA_PACKAGES = [
|
|
"com.google.android.apps.mediashell", # Chromecast receiver process
|
|
"com.android.systemui",
|
|
"com.android.tv.settings",
|
|
"com.nvidia.diagtools",
|
|
]
|
|
|
|
|
|
# t44: per-command script overrides — the escape hatch for hardware this
|
|
# integration deliberately does not drive itself (a speaker switch that owns the
|
|
# volume, an AVR input, a mute that has to hit two devices). One script per
|
|
# command: when one is configured, that command calls the script INSTEAD of the
|
|
# native protocol/ADB path, and with none configured nothing changes at all.
|
|
CONF_COMMAND_SCRIPTS = "command_scripts"
|
|
SCRIPT_COMMANDS = (
|
|
"turn_on",
|
|
"turn_off",
|
|
"volume_up",
|
|
"volume_down",
|
|
"volume_set",
|
|
"volume_mute",
|
|
"select_source",
|
|
)
|
|
#: Options-flow field name per command: ``script_turn_on``, ``script_volume_set``…
|
|
SCRIPT_FIELD_PREFIX = "script_"
|
|
#: Variables a command hands its script (HA script variables). volume_mute passes
|
|
#: whether it is muting or unmuting — a script cannot tell those apart otherwise.
|
|
SCRIPT_VAR_LEVEL = "level"
|
|
SCRIPT_VAR_APP_NAME = "app_name"
|
|
SCRIPT_VAR_MUTED = "is_volume_muted"
|
|
|
|
|
|
def command_scripts(options) -> dict[str, str]:
|
|
"""The script configured per command, normalized (t44).
|
|
|
|
The ONE place the stored option's shape is read: the options flow, the media
|
|
player and the remote entity all go through it. Options round-trip through HA
|
|
storage and can be hand-edited, so a value that is not a non-empty string for a
|
|
known command is dropped rather than handed to the service layer.
|
|
"""
|
|
raw = (options or {}).get(CONF_COMMAND_SCRIPTS)
|
|
if not isinstance(raw, dict):
|
|
return {}
|
|
scripts: dict[str, str] = {}
|
|
for command in SCRIPT_COMMANDS:
|
|
script = raw.get(command)
|
|
if isinstance(script, str) and script.strip():
|
|
scripts[command] = script.strip()
|
|
return scripts
|
|
|
|
|
|
# t47: ONE optional script for the remote entity's send_command. It replaces the
|
|
# whole key path (repeats, hold, delays included) rather than per-key overrides,
|
|
# because a remote command is a sequence: an IR bridge, a macro or a second device
|
|
# needs the call, not individual keys.
|
|
CONF_REMOTE_COMMAND_SCRIPT = "remote_command_script"
|
|
#: Variables remote.send_command hands its script — the names are the service's own
|
|
#: parameter names, and they are documented in the README and pinned in tests.
|
|
SCRIPT_VAR_COMMAND = "command"
|
|
SCRIPT_VAR_NUM_REPEATS = "num_repeats"
|
|
SCRIPT_VAR_DELAY_SECS = "delay_secs"
|
|
SCRIPT_VAR_HOLD_SECS = "hold_secs"
|
|
|
|
# t66: the screen-capture image entity — OFF by default. A capture is a
|
|
# full-resolution screen grab, and the user's standing constraint is that screen
|
|
# capture must never drive state; opt-in keeps a diagnostic surface from costing
|
|
# every install a grab per dashboard view.
|
|
CONF_SCREEN_CAPTURE_ENABLED = "screen_capture_enabled"
|
|
|
|
def remote_command_script(options) -> str | None:
|
|
"""The script configured for remote.send_command, or None (t47).
|
|
|
|
Same defensive read as ``command_scripts``: options round-trip through HA
|
|
storage and can be hand-edited, so anything that is not a non-empty string is
|
|
"not configured" rather than a service call to nowhere.
|
|
"""
|
|
script = (options or {}).get(CONF_REMOTE_COMMAND_SCRIPT)
|
|
if isinstance(script, str) and script.strip():
|
|
return script.strip()
|
|
return None
|