"""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" #: t15: a PERSISTED marker that this entry's protocol client really paired (a #: successful ``async_finish_pairing``). Pairing cannot be inferred from anything #: else: the library exposes no ``is_paired``, and certificate material proves only #: that SOMETHING generated a key — which an entry that merely enabled the transport #: without pairing has too. Written by the setup flow's pairing step, the options #: flow's, and the reauth/reconfigure finishers, so enabling the transport later can #: tell "already paired" (reuse the certificate, no new PIN) from "never paired" #: (must pair now, or stay off). CONF_PROTOCOL_PAIRED = "protocol_paired" 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 # t15: the remote-protocol transport's own state machine. The library offers NO # ``is_paired``/``is_connected`` (measured on androidtvremote2), so these are the # states we can actually prove — and the one the user was reading as "maybe # unpaired" was ``never_connected``: keep_reconnecting() alone never connects # (its loop is ``while self._remote_message_protocol:``, None until a first # ``async_connect()``), so the transport had never been switched on at all. PROTOCOL_OFF = "off" # no client wired (disabled/no library) PROTOCOL_NEVER_CONNECTED = "never_connected" # wired, no session yet, no rejection PROTOCOL_CONNECTED = "connected" # device reachable at the protocol PROTOCOL_DISCONNECTED = "disconnected" # session was up, now reported down PROTOCOL_AUTH_REJECTED = "auth_rejected" # cert refused: pair again (reauth) #: Seconds allowed for the FIRST protocol connect before it counts as unreachable. #: Core androidtv_remote uses the same 5 s before deciding ready/not-ready. PROTOCOL_CONNECT_TIMEOUT = 5.0 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