The reported Shield names no app: `pm dump com.netflix.ninja` is ~2241 lines of
dumpsys and grep for `application-label` over the whole capture finds nothing.
The probe shipped `| head -300`, which cut the dump BEFORE the package section,
so "no label" was a truncation artefact; the sweep then named every app after
its own package id, and the apps editor pre-filled the `pkg=pkg` row the user
read as a bug.
* the probe greps ON THE DEVICE for the three shapes Android prints
(`application-label`, `nonLocalizedLabel`, `labelRes`); a quoted value is a
name, `null` and a resource id are not. The dump is never truncated.
* after APP_LABEL_PROBE_LIMIT consecutive "ran, no label" answers the device
is taken at its word: the sweep stops spending a shell per package
(APP_LABEL_TTL expiry re-checks, and a package whose probe FAILED still owes
its retry — t57 UNIT 0 survives, driven by the app on screen).
* ONE precedence chain in `ha_adb_common/app_label.resolve_package_name`, used
by the media player, the Current App sensor, the discovery table, the source
list and the apps editor: user map > device label > curated map > package id
(the id shown ONCE, `name` empty when nothing names the app).
* the curated table covers the user's 21 apps (community-maintained, not
device truth) and the Configure row says when the device named nothing.
* the real device shape is a test fixture (trimmed, and marked as such in the
file: the raw capture is not in the repo) pinning that no label is
extracted, that the probe count is bounded, and that `Netflix` surfaces.
Also drops the dead `ATTR_MEDIA_DAG_NOW_PLAYING` re-exports left by the t65
flattening and updates the RESEARCH_SHIELD now-playing note to the flat names.
No real Shield was available: the fetch/burst behaviour is exercised against the
documented Android output and the trimmed reproduction of the reported capture.
327 lines
14 KiB
Python
327 lines
14 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: rawADB shell on this entry — OFF by default
|
|
# (same security posture as the core androidtv integration).
|
|
CONF_ADB_COMMAND_ENABLED = "adb_command_enabled"
|
|
SERVICE_ADB_COMMAND = "adb_command"
|
|
|
|
# P5-3 (C-STREAM): live events push — the streaming_shell logcat consumer
|
|
# (foreground AppState fast-path). Default OFF; the user flips it on and
|
|
# reports stability on their device.
|
|
CONF_LOGCAT_EVENTS = "logcat_events"
|
|
|
|
# P5-3: duplicate START for the same package inside this window is not
|
|
# re-stamped (repeat suppression).
|
|
LOGCAT_SUPPRESS_WINDOW = 15.0
|
|
|
|
# P4-2c/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
|