Initial release v0.1.0

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>
This commit is contained in:
LeviandClaude Opus 4.7 committed 2026-04-17 17:04:30 -05:00
commit 0ad51879ae
24 files changed
+2538

No files matched your search

@@ -0,0 +1,40 @@
"""The Nvidia Shield integration."""
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.SENSOR, Platform.BUTTON]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Nvidia Shield from a config entry."""
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = entry
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
@@ -0,0 +1,37 @@
"""ADB media session reader for NVIDIA Shield.
Thin wrapper around ha_adb_common.AdbClientBase — adds the Shield-specific
`poll()` method that calls wake check + media session in sequence.
Re-exports MediaSessionInfo and ensure_adb_key for existing importers.
"""
import logging
from .ha_adb_common import AdbClientBase, ensure_adb_key as _ensure_adb_key
from .ha_adb_common import MediaSessionInfo # noqa: F401 (re-export)
_LOGGER = logging.getLogger(__name__)
DOMAIN = "nvidia_shield"
def ensure_adb_key(hass_config_dir: str) -> str:
"""Ensure an ADB key pair exists for the nvidia_shield integration."""
return _ensure_adb_key(hass_config_dir, DOMAIN)
class AdbMediaSessionPoller(AdbClientBase):
"""ADB client for NVIDIA Shield — extends base with wake-state tracking."""
def __init__(self, host: str, port: int, key_path: str):
super().__init__(host, port, key_path)
async def poll(self, foreground_package: str | None = None) -> MediaSessionInfo | None:
"""Query the current media session. Returns parsed info or None.
Args:
foreground_package: The package name of the foreground app, used to
prefer its media session over stale sessions from other apps.
"""
return await self.poll_media_sessions(foreground_package)
+102
View File
@@ -0,0 +1,102 @@
"""Button entities for NVIDIA Shield."""
import logging
from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .adb_session import AdbMediaSessionPoller, ensure_adb_key
from .const import (
CONF_ADB_ENABLED,
CONF_ADB_HOST,
CONF_ADB_PORT,
CONF_ANDROIDTV_ENTITY,
DEFAULT_ADB_PORT,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
if entry.options.get(CONF_ADB_ENABLED, False):
async_add_entities([NvidiaShieldPairAdbButton(hass, entry)])
class NvidiaShieldPairAdbButton(ButtonEntity):
"""Button to initiate or re-pair the ADB connection."""
_attr_has_entity_name = True
_attr_name = "Pair ADB"
_attr_icon = "mdi:link-variant"
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
self.hass = hass
self._entry = entry
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_pair_adb"
@property
def device_info(self) -> DeviceInfo:
return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)},
name=self._entry.title,
manufacturer="NVIDIA",
model="Shield TV",
)
def _resolve_host(self) -> str | None:
"""Resolve ADB host from config or Android TV entity."""
host = self._entry.options.get(CONF_ADB_HOST)
if host:
return host
atv_entity = self._entry.data.get(CONF_ANDROIDTV_ENTITY)
if not atv_entity:
return None
registry = er.async_get(self.hass)
entity_entry = registry.async_get(atv_entity)
if not entity_entry or not entity_entry.config_entry_id:
return None
config_entry = self.hass.config_entries.async_get_entry(entity_entry.config_entry_id)
if config_entry:
return config_entry.data.get("host")
return None
async def async_press(self) -> None:
"""Attempt ADB connection. The Shield will show a pairing prompt."""
host = self._resolve_host()
port = self._entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT)
if not host:
_LOGGER.error("Could not determine ADB host. Set it in integration options.")
return
key_path = await self.hass.async_add_executor_job(
ensure_adb_key, self.hass.config.config_dir
)
_LOGGER.info("Attempting ADB pairing with %s:%d — approve the prompt on the Shield", host, port)
poller = AdbMediaSessionPoller(host, port, key_path)
if await poller.connect():
_LOGGER.info("ADB pairing successful with %s", host)
await poller.disconnect()
# Reload to start polling
await self.hass.config_entries.async_reload(self._entry.entry_id)
else:
_LOGGER.error(
"ADB pairing failed with %s. Make sure Network debugging is enabled "
"and you approved the prompt on the Shield.",
host,
)
await poller.disconnect()
@@ -0,0 +1,327 @@
"""Config flow for Nvidia Shield integration."""
import logging
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.helpers import entity_registry as er, selector
from .adb_session import AdbMediaSessionPoller, ensure_adb_key
from .const import (
ATTR_MEDIA_DAG_INPUT_MAP,
CONF_ADB_ENABLED,
CONF_ADB_HOST,
CONF_ADB_PORT,
CONF_ADB_STORE_APP_INFO,
CONF_ANDROIDTV_ENTITY,
CONF_JELLYFIN_ENTITY,
CONF_POWER_ENTITY,
CONF_RECEIVER_ENTITY,
CONF_RECEIVER_INPUT,
DEFAULT_ADB_PORT,
DEFAULT_NAME,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
def _get_receiver_inputs(hass, receiver_entity_id: str | None) -> list[str]:
"""Read available inputs from a receiver's media_dag_input_map attribute."""
if not receiver_entity_id:
return []
state = hass.states.get(receiver_entity_id)
if not state:
return []
input_map = state.attributes.get(ATTR_MEDIA_DAG_INPUT_MAP, {})
return list(input_map.keys()) if isinstance(input_map, dict) else []
def _find_jellyfin_entity(hass, device_name: str) -> str | None:
"""Auto-detect the Jellyfin media_player entity for a device."""
registry = er.async_get(hass)
device_name_lower = device_name.lower()
candidates = []
for entry in registry.entities.values():
if entry.domain != "media_player" or entry.platform != "jellyfin":
continue
if entry.disabled_by is not None:
continue
eid_lower = entry.entity_id.lower()
name_lower = (entry.original_name or "").lower()
if device_name_lower in eid_lower or device_name_lower in name_lower:
state = hass.states.get(entry.entity_id)
if state and state.state != "unavailable":
candidates.append(entry.entity_id)
if candidates:
if len(candidates) > 1:
_LOGGER.warning(
"Multiple Jellyfin entities found for %s: %s. Using first.",
device_name, candidates,
)
return candidates[0]
return None
def _build_selectors():
"""Build reusable selector instances."""
return {
"androidtv": selector.EntitySelector(
selector.EntitySelectorConfig(domain="media_player", device_class="tv")
),
"media_player": selector.EntitySelector(
selector.EntitySelectorConfig(domain="media_player")
),
"receiver": selector.EntitySelector(
selector.EntitySelectorConfig(domain="media_player", device_class="receiver")
),
"power": selector.EntitySelector(
selector.EntitySelectorConfig(domain=["switch", "binary_sensor"])
),
}
class NvidiaShieldConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Nvidia Shield."""
VERSION = 1
def __init__(self):
self._user_data = {}
async def async_step_user(self, user_input=None):
"""Step 1: Basic setup."""
errors = {}
if user_input is not None:
androidtv_entity = user_input[CONF_ANDROIDTV_ENTITY]
await self.async_set_unique_id(androidtv_entity)
self._abort_if_unique_id_configured()
# Auto-detect Jellyfin if not manually specified
jellyfin = user_input.get(CONF_JELLYFIN_ENTITY)
if not jellyfin:
device_name = user_input.get("name", DEFAULT_NAME)
atv_state = self.hass.states.get(androidtv_entity)
atv_name = (atv_state.attributes.get("friendly_name", "") if atv_state else "")
for name in [device_name, atv_name]:
if not name:
continue
found = _find_jellyfin_entity(self.hass, name)
if found:
jellyfin = found
_LOGGER.info("Auto-detected Jellyfin entity: %s", found)
break
self._user_data = {
"name": user_input.get("name", DEFAULT_NAME),
CONF_ANDROIDTV_ENTITY: androidtv_entity,
CONF_JELLYFIN_ENTITY: jellyfin or "",
CONF_RECEIVER_ENTITY: user_input.get(CONF_RECEIVER_ENTITY, ""),
CONF_RECEIVER_INPUT: user_input.get(CONF_RECEIVER_INPUT, ""),
CONF_POWER_ENTITY: user_input.get(CONF_POWER_ENTITY, ""),
CONF_ADB_ENABLED: user_input.get(CONF_ADB_ENABLED, False),
CONF_ADB_STORE_APP_INFO: user_input.get(CONF_ADB_STORE_APP_INFO, True),
CONF_ADB_HOST: user_input.get(CONF_ADB_HOST, ""),
CONF_ADB_PORT: user_input.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
}
# If ADB is enabled, go to pairing step
if self._user_data[CONF_ADB_ENABLED]:
return await self.async_step_adb_pair()
return self._create_entry()
sel = _build_selectors()
schema_dict = {
vol.Required("name", default=DEFAULT_NAME): str,
vol.Required(CONF_ANDROIDTV_ENTITY): sel["androidtv"],
vol.Optional(CONF_JELLYFIN_ENTITY): sel["media_player"],
vol.Optional(CONF_RECEIVER_ENTITY): sel["receiver"],
}
# If there are any receivers with DAG input maps, show a text field
# for receiver input (will be a dropdown in options once receiver is set)
schema_dict[vol.Optional(CONF_RECEIVER_INPUT)] = str
schema_dict[vol.Optional(CONF_POWER_ENTITY)] = sel["power"]
schema_dict[vol.Optional(CONF_ADB_ENABLED, default=False)] = bool
schema_dict[vol.Optional(CONF_ADB_STORE_APP_INFO, default=True)] = bool
schema_dict[vol.Optional(CONF_ADB_HOST)] = str
schema_dict[vol.Optional(CONF_ADB_PORT, default=DEFAULT_ADB_PORT)] = int
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(schema_dict),
errors=errors,
)
def _create_entry(self):
"""Create the config entry from stored user data."""
d = self._user_data
return self.async_create_entry(
title=d["name"],
data={CONF_ANDROIDTV_ENTITY: d[CONF_ANDROIDTV_ENTITY]},
options={
CONF_JELLYFIN_ENTITY: d.get(CONF_JELLYFIN_ENTITY, ""),
CONF_RECEIVER_ENTITY: d.get(CONF_RECEIVER_ENTITY, ""),
CONF_RECEIVER_INPUT: d.get(CONF_RECEIVER_INPUT, ""),
CONF_POWER_ENTITY: d.get(CONF_POWER_ENTITY, ""),
CONF_ADB_ENABLED: d.get(CONF_ADB_ENABLED, False),
CONF_ADB_STORE_APP_INFO: d.get(CONF_ADB_STORE_APP_INFO, True),
CONF_ADB_HOST: d.get(CONF_ADB_HOST, ""),
CONF_ADB_PORT: d.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
},
)
def _resolve_adb_host(self) -> str | None:
"""Resolve ADB host from explicit config or the Android TV entity."""
host = self._user_data.get(CONF_ADB_HOST)
if host:
return host
atv_entity = self._user_data.get(CONF_ANDROIDTV_ENTITY)
if not atv_entity:
return None
registry = er.async_get(self.hass)
entity_entry = registry.async_get(atv_entity)
if not entity_entry or not entity_entry.config_entry_id:
return None
config_entry = self.hass.config_entries.async_get_entry(entity_entry.config_entry_id)
if config_entry:
return config_entry.data.get("host")
return None
async def async_step_adb_pair(self, user_input=None):
"""Step 2: ADB pairing. User clicks Submit after approving on the Shield."""
errors = {}
if user_input is not None:
host = self._resolve_adb_host()
port = self._user_data.get(CONF_ADB_PORT, DEFAULT_ADB_PORT)
if not host:
errors["base"] = "adb_no_host"
else:
key_path = await self.hass.async_add_executor_job(
ensure_adb_key, self.hass.config.config_dir
)
poller = AdbMediaSessionPoller(host, port, key_path)
connected = await poller.connect()
await poller.disconnect()
if connected:
return self._create_entry()
else:
errors["base"] = "adb_failed"
return self.async_show_form(
step_id="adb_pair",
data_schema=vol.Schema({}),
errors=errors,
)
@staticmethod
@callback
def async_get_options_flow(config_entry):
return NvidiaShieldOptionsFlow(config_entry)
class NvidiaShieldOptionsFlow(config_entries.OptionsFlow):
"""Handle options for Nvidia Shield — single unified page."""
def __init__(self, config_entry):
self._config_entry = config_entry
self._options = dict(config_entry.options)
async def async_step_init(self, user_input=None):
"""Single-page options."""
if user_input is not None:
self._options.update(user_input)
return self.async_create_entry(title="", data=self._options)
sel = _build_selectors()
schema_dict = {}
# Jellyfin
jf = self._options.get(CONF_JELLYFIN_ENTITY)
jf_key = vol.Optional(CONF_JELLYFIN_ENTITY)
if jf:
jf_key = vol.Optional(CONF_JELLYFIN_ENTITY, default=jf)
schema_dict[jf_key] = sel["media_player"]
# Receiver
recv = self._options.get(CONF_RECEIVER_ENTITY)
recv_key = vol.Optional(CONF_RECEIVER_ENTITY)
if recv:
recv_key = vol.Optional(CONF_RECEIVER_ENTITY, default=recv)
schema_dict[recv_key] = sel["receiver"]
# Receiver input — dropdown from DAG if receiver is set, text otherwise
current_input = self._options.get(CONF_RECEIVER_INPUT, "")
receiver = self._options.get(CONF_RECEIVER_ENTITY)
inputs = _get_receiver_inputs(self.hass, receiver)
if inputs:
input_sel = 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, default=current_input)
schema_dict[input_key] = input_sel
else:
schema_dict[vol.Optional(
CONF_RECEIVER_INPUT, default=current_input
)] = str
# Power
pwr = self._options.get(CONF_POWER_ENTITY)
pwr_key = vol.Optional(CONF_POWER_ENTITY)
if pwr:
pwr_key = vol.Optional(CONF_POWER_ENTITY, default=pwr)
schema_dict[pwr_key] = sel["power"]
# ADB options
schema_dict[vol.Optional(
CONF_ADB_ENABLED,
default=self._options.get(CONF_ADB_ENABLED, False),
)] = bool
schema_dict[vol.Optional(
CONF_ADB_STORE_APP_INFO,
default=self._options.get(CONF_ADB_STORE_APP_INFO, True),
)] = bool
# ADB host/port (optional overrides, shown when ADB is enabled)
adb_host = self._options.get(CONF_ADB_HOST, "")
schema_dict[vol.Optional(
CONF_ADB_HOST,
default=adb_host,
)] = str
schema_dict[vol.Optional(
CONF_ADB_PORT,
default=self._options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT),
)] = int
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(schema_dict),
)
+38
View File
@@ -0,0 +1,38 @@
"""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_NOW_PLAYING,
ATTR_MEDIA_DAG_RECEIVER_ENTITY,
ATTR_MEDIA_DAG_RECEIVER_INPUT,
ATTR_MEDIA_DAG_ROLE,
ATTR_MEDIA_DAG_RUNNING_APPS,
)
DOMAIN = "nvidia_shield"
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_HOST = "adb_host"
CONF_ADB_PORT = "adb_port"
ADB_POLL_INTERVAL = 10 # seconds
ADB_RETRY_INTERVAL = 300 # seconds before retrying a failed ADB connection
DEFAULT_ADB_PORT = 5555
DEFAULT_NAME = "Nvidia Shield"
# Emotiva DAG attribute we read to get available inputs
ATTR_MEDIA_DAG_INPUT_MAP = "media_dag_input_map"
# Known Jellyfin app package IDs
JELLYFIN_APP_IDS = [
"org.jellyfin.androidtv",
"org.jellyfin.mobile",
]
@@ -0,0 +1,32 @@
"""Shared ADB utilities for Home Assistant custom integrations."""
from .adb_client import AdbClientBase
from .const import (
ATTR_MEDIA_DAG_JELLYFIN_ENTITY,
ATTR_MEDIA_DAG_NOW_PLAYING,
ATTR_MEDIA_DAG_RECEIVER_ENTITY,
ATTR_MEDIA_DAG_RECEIVER_INPUT,
ATTR_MEDIA_DAG_ROLE,
ATTR_MEDIA_DAG_RUNNING_APPS,
)
from .intent import PlaybackIntent
from .keys import ensure_adb_key
from .media_session import MediaSessionInfo, parse_all_media_sessions, parse_media_session
from .running_apps import running_apps_from_sessions, running_apps_from_tasks
__all__ = [
"ATTR_MEDIA_DAG_JELLYFIN_ENTITY",
"ATTR_MEDIA_DAG_NOW_PLAYING",
"ATTR_MEDIA_DAG_RECEIVER_ENTITY",
"ATTR_MEDIA_DAG_RECEIVER_INPUT",
"ATTR_MEDIA_DAG_ROLE",
"ATTR_MEDIA_DAG_RUNNING_APPS",
"AdbClientBase",
"MediaSessionInfo",
"PlaybackIntent",
"ensure_adb_key",
"parse_all_media_sessions",
"parse_media_session",
"running_apps_from_sessions",
"running_apps_from_tasks",
]
@@ -0,0 +1,213 @@
"""Base ADB client with shared connection management and commands.
Subclasses add device-specific polling (media sessions, wake state, etc.).
"""
import logging
from .media_session import MediaSessionInfo, parse_all_media_sessions, parse_media_session
_LOGGER = logging.getLogger(__name__)
# Standard Android media key codes
MEDIA_KEYCODES = {
"play": 126,
"pause": 127,
"play-pause": 85,
"stop": 86,
"next": 87,
"previous": 88,
}
DEVICE_KEYCODES = {
"power": 26,
"sleep": 223,
"wakeup": 224,
"volume_up": 24,
"volume_down": 25,
"mute": 164,
}
ALL_KEYCODES = {**MEDIA_KEYCODES, **DEVICE_KEYCODES}
class AdbClientBase:
"""Base ADB client with connection management and common commands."""
def __init__(self, host: str, port: int, key_path: str):
self._host = host
self._port = port
self._key_path = key_path
self._device = None
self._connected = False
# Media session state
self._last_info: MediaSessionInfo | None = None
self._all_sessions: list[MediaSessionInfo] = []
@property
def connected(self) -> bool:
return self._connected
@property
def last_info(self) -> MediaSessionInfo | None:
return self._last_info
@property
def all_sessions(self) -> list[MediaSessionInfo]:
return self._all_sessions
def clear_info(self) -> None:
"""Clear the cached media session info."""
self._last_info = None
self._all_sessions = []
async def connect(self) -> bool:
"""Connect to the device via ADB."""
try:
import asyncio
from adb_shell.adb_device_async import AdbDeviceTcpAsync
from adb_shell.auth.sign_pythonrsa import PythonRSASigner
# FromRSAKeyPath does blocking file I/O — run it in the default
# executor so we don't stall Home Assistant's event loop.
loop = asyncio.get_running_loop()
signer = await loop.run_in_executor(
None, PythonRSASigner.FromRSAKeyPath, self._key_path
)
self._device = AdbDeviceTcpAsync(self._host, self._port)
await self._device.connect(rsa_keys=[signer], auth_timeout_s=10)
self._connected = True
_LOGGER.info("ADB connected to %s:%d", self._host, self._port)
return True
except Exception as err:
_LOGGER.debug("ADB connection failed: %s", err)
self._connected = False
self._device = None
return False
async def disconnect(self) -> None:
"""Disconnect from the device."""
if self._device:
try:
await self._device.close()
except Exception:
pass
self._device = None
self._connected = False
async def shell(self, cmd: str) -> str | None:
"""Run a shell command, reconnecting if needed."""
if not self._connected or not self._device:
if not await self.connect():
return None
try:
return await self._device.shell(cmd)
except Exception as err:
_LOGGER.debug("ADB shell failed: %s", err)
self._connected = False
self._device = None
return None
# -- Common commands --
async def send_keyevent(self, command: str) -> bool:
"""Send a key event via ADB input keyevent."""
keycode = ALL_KEYCODES.get(command)
if keycode is None:
_LOGGER.warning("Unknown keyevent command: %s", command)
return False
result = await self.shell(f"input keyevent {keycode}")
if result is not None:
_LOGGER.debug("ADB keyevent %d (%s)", keycode, command)
return result is not None
async def send_media_command(self, command: str) -> bool:
"""Send a media command via ADB input keyevent."""
keycode = MEDIA_KEYCODES.get(command)
if keycode is None:
_LOGGER.warning("Unknown media command: %s", command)
return False
result = await self.shell(f"input keyevent {keycode}")
if result is not None:
_LOGGER.debug("ADB keyevent %d (%s)", keycode, command)
return True
return False
async def bring_to_foreground(self, package: str) -> bool:
"""Bring an app to the foreground using monkey command."""
result = await self.shell(
f"monkey -p {package} -c android.intent.category.LAUNCHER 1 2>/dev/null"
)
if result is not None:
_LOGGER.debug("Brought %s to foreground via monkey", package)
return result is not None
async def is_awake(self) -> bool | None:
"""Check if the device screen is on / awake via ADB."""
result = await self.shell("dumpsys power 2>/dev/null | grep mWakefulness")
if result:
return "Awake" in result
return None
# -- Media session polling --
async def poll_media_sessions(
self, foreground_package: str | None = None, head_lines: int = 200
) -> MediaSessionInfo | None:
"""Query media sessions. Stores all sessions and returns the best match."""
dump = await self.shell(
f"dumpsys media_session 2>/dev/null | head -{head_lines}"
)
if dump is None:
return None
self._all_sessions = parse_all_media_sessions(dump)
self._last_info = parse_media_session(dump, foreground_package)
return self._last_info
# -- Running apps (recent visible tasks) --
async def poll_running_apps(self) -> list[dict]:
"""Get running apps from Android's recent tasks.
Returns a list of dicts with 'package' and 'foreground' keys,
ordered by recency (most recent first). Only includes visible
user apps, filtering out system UI, home, and invisible tasks.
"""
result = await self.shell(
"dumpsys activity recents 2>/dev/null "
"| grep -E 'Recent #|realActivity|visible='"
)
if not result:
return []
import re
apps = []
seen = set()
# System packages to exclude
_EXCLUDED = {
"com.android.systemui",
"com.android.tv.settings",
"com.nvidia.diagtools",
"android",
}
for line in result.split("\n"):
stripped = line.strip()
# Match recent task lines with visibility and package info
if "Recent #" in stripped and "visible=true" in stripped:
# Extract package from A=uid:package pattern
match = re.search(r"A=\d+:(\S+)", stripped)
if not match:
# Try I=package/activity pattern
match = re.search(r"I=(\S+)/", stripped)
if match:
pkg = match.group(1)
if pkg not in seen and pkg not in _EXCLUDED and not pkg.startswith("."):
seen.add(pkg)
apps.append({"package": pkg})
return apps
@@ -0,0 +1,25 @@
"""Shared Media DAG attribute keys.
These constants define the contract between source integrations (nvidia_shield,
hisense_tv, etc.) and the media_zone aggregator. All source integrations MUST
use these exact attribute keys so media_zone can read them uniformly.
"""
# Role of this entity in the media DAG
ATTR_MEDIA_DAG_ROLE = "media_dag_role"
# Receiver this source is wired to (entity_id)
ATTR_MEDIA_DAG_RECEIVER_ENTITY = "media_dag_receiver_entity"
# Which receiver input this source occupies
ATTR_MEDIA_DAG_RECEIVER_INPUT = "media_dag_receiver_input"
# Jellyfin media_player entity for rich metadata
ATTR_MEDIA_DAG_JELLYFIN_ENTITY = "media_dag_jellyfin_entity"
# Now-playing dict: {state, title, media_type, series?, season?, episode?,
# duration?, position?, thumbnail?, artist?, album?, media_info?, source?}
ATTR_MEDIA_DAG_NOW_PLAYING = "media_dag_now_playing"
# List of running app dicts: [{package, state, title?, artist?}, ...]
ATTR_MEDIA_DAG_RUNNING_APPS = "media_dag_running_apps"
@@ -0,0 +1,48 @@
"""Playback intent tracking for optimistic UI updates.
Bridges the gap between instant commands and slow state polling.
"""
import time
from dataclasses import dataclass
@dataclass
class PlaybackIntent:
"""A pending user intent for playback state.
Attributes:
target_state: The MediaPlayerState we expect after the command.
command: The service/command name (for retries and logging).
created_at: Monotonic timestamp when the intent was created.
prior_state: The source state before the intent (for detecting external overrides).
retry_count: Number of retries attempted so far.
"""
target_state: str # MediaPlayerState value (e.g. "playing", "paused")
command: str
created_at: float
prior_state: str | None = None
retry_count: int = 0
@classmethod
def create(cls, target_state: str, command: str, prior_state: str | None = None) -> "PlaybackIntent":
return cls(
target_state=target_state,
command=command,
created_at=time.monotonic(),
prior_state=prior_state,
)
@property
def age_seconds(self) -> float:
return round(time.monotonic() - self.created_at, 1)
def to_debug_dict(self) -> dict:
"""Serialize for the _intent debug attribute."""
return {
"target": self.target_state,
"command": self.command,
"retries": self.retry_count,
"age_s": self.age_seconds,
}
@@ -0,0 +1,56 @@
"""ADB key management — generation and migration."""
import logging
import os
import shutil
_LOGGER = logging.getLogger(__name__)
def ensure_adb_key(
hass_config_dir: str,
domain: str,
legacy_paths: list[str] | None = None,
) -> str:
"""Ensure an ADB key pair exists. Migrate from legacy paths or generate if missing.
Args:
hass_config_dir: Home Assistant config directory.
domain: Integration domain name (e.g. "nvidia_shield", "hisense_tv").
Used to namespace the key storage.
legacy_paths: Optional list of legacy key paths to check for migration,
in priority order. Avoids forcing device re-pairing.
Returns:
Path to the private key file.
"""
key_dir = os.path.join(hass_config_dir, ".storage", domain)
os.makedirs(key_dir, exist_ok=True)
key_path = os.path.join(key_dir, "adbkey")
if os.path.exists(key_path):
return key_path
# Try migrating from legacy locations
all_legacy = list(legacy_paths or [])
# Always check the generic location as a last resort
generic = os.path.join(hass_config_dir, ".storage", "adbkey")
if generic not in all_legacy:
all_legacy.append(generic)
for legacy_key in all_legacy:
if os.path.exists(legacy_key):
shutil.copy2(legacy_key, key_path)
pub = legacy_key + ".pub"
if os.path.exists(pub):
shutil.copy2(pub, key_path + ".pub")
_LOGGER.info("Migrated ADB key from %s to %s", legacy_key, key_path)
return key_path
# Generate new key
from adb_shell.auth.keygen import keygen
keygen(key_path)
_LOGGER.info("Generated new ADB key at %s", key_path)
return key_path
@@ -0,0 +1,122 @@
"""Android media session parsing.
Parses `dumpsys media_session` output from ADB to extract now-playing
information from any app using Android's MediaSession API.
"""
from dataclasses import dataclass
@dataclass
class MediaSessionInfo:
"""Parsed media session data from Android."""
title: str | None = None
artist: str | None = None
album: str | None = None
package: str | None = None
playing: bool = False
paused: bool = False
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.
"""
if not dump:
return []
sessions: list[MediaSessionInfo] = []
current_pkg: str | None = None
in_active = False
current_state = 0 # 0=unknown, 2=paused, 3=playing
current_meta: MediaSessionInfo | None = None
for line in dump.split("\n"):
stripped = line.strip()
if "package=" in stripped:
pkg = stripped.split("package=", 1)
if len(pkg) > 1:
# New session block — save the previous one if valid
if in_active and current_meta:
current_meta.package = current_pkg
sessions.append(current_meta)
current_pkg = pkg[1].strip()
in_active = False
current_state = 0
current_meta = None
if "active=true" in stripped:
in_active = True
if in_active and "state=PlaybackState" in stripped:
if "state=3" in stripped:
current_state = 3
elif "state=2" in stripped:
current_state = 2
if in_active and "metadata: size=" in stripped and "description=" in stripped:
desc_start = stripped.find("description=")
if desc_start < 0:
continue
desc = stripped[desc_start + 12:]
parts = [p.strip() for p in desc.split(", ")]
info = MediaSessionInfo()
info.package = current_pkg
info.playing = current_state == 3
info.paused = current_state == 2
if len(parts) >= 1 and parts[0] not in ("null", ""):
info.title = parts[0]
if len(parts) >= 2 and parts[1] not in ("null", ""):
info.artist = parts[1]
if len(parts) >= 3 and parts[2] not in ("null", ""):
info.album = parts[2]
if info.title:
current_meta = info
# Don't forget the last session
if in_active and current_meta:
current_meta.package = current_pkg
sessions.append(current_meta)
# Sort: playing first, then paused, then others
def sort_key(s: MediaSessionInfo) -> int:
if s.playing:
return 0
if s.paused:
return 1
return 2
sessions.sort(key=sort_key)
return sessions
def parse_media_session(
dump: str, foreground_package: str | None = None
) -> MediaSessionInfo | None:
"""Parse `dumpsys media_session` output for the best active 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
"""
sessions = parse_all_media_sessions(dump)
if not sessions:
return None
# Prefer the session matching the foreground app
if foreground_package:
for s in sessions:
if s.package == foreground_package:
return s
# Already sorted: playing > paused > other
return sessions[0]
@@ -0,0 +1,72 @@
"""Build the running apps list from recent tasks + media sessions.
Produces the media_dag_running_apps attribute value that media_zone reads.
Recent tasks (from dumpsys activity recents) provide the list of running apps.
Media sessions provide playback state for apps that have active sessions.
"""
from .media_session import MediaSessionInfo
def running_apps_from_tasks(
recent_tasks: list[dict],
media_sessions: list[MediaSessionInfo] | None = None,
foreground_package: str | None = None,
) -> list[dict] | None:
"""Build a list of running app dicts from recent tasks + media sessions.
Args:
recent_tasks: List of {"package": str} from ADB recent tasks.
media_sessions: Optional media sessions for playback state enrichment.
foreground_package: The current foreground app package.
Returns None if there are fewer than 2 apps (no point showing a switcher
with only one option).
"""
if not recent_tasks or len(recent_tasks) < 2:
return None
# Index media sessions by package for quick lookup
session_by_pkg: dict[str, MediaSessionInfo] = {}
if media_sessions:
for s in media_sessions:
if s.package:
session_by_pkg[s.package] = s
apps = []
for task in recent_tasks:
pkg = task["package"]
app: dict = {"package": pkg}
# Mark foreground app
if pkg == foreground_package:
app["foreground"] = True
# Enrich with media session data if available
session = session_by_pkg.get(pkg)
if session:
if session.playing:
app["state"] = "playing"
elif session.paused:
app["state"] = "paused"
else:
app["state"] = "idle"
if session.title:
app["title"] = session.title
if session.artist:
app["artist"] = session.artist
else:
app["state"] = "running"
apps.append(app)
return apps if len(apps) >= 2 else None
# Keep backward compat for any code still using this
def running_apps_from_sessions(sessions: list[MediaSessionInfo]) -> list[dict] | None:
"""Build running apps from media sessions only (legacy)."""
if not sessions:
return None
tasks = [{"package": s.package} for s in sessions if s.package]
return running_apps_from_tasks(tasks, sessions)
+133
View File
@@ -0,0 +1,133 @@
"""Jellyfin session stream-info reader.
Piggybacks on HA's built-in Jellyfin integration: its coordinator polls
/Sessions every 10s and exposes the parsed result on
``config_entry.runtime_data.data``. We reach across to that cached dict
by walking from the user-selected Jellyfin media_player entity back to
its owning Jellyfin config entry. Zero additional config, zero extra
HTTP.
"""
import logging
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
_LOGGER = logging.getLogger(__name__)
def _parse_streams(item: dict) -> dict | None:
"""Extract friendly video/audio labels from a Jellyfin NowPlayingItem."""
streams = item.get("MediaStreams", [])
result: dict = {}
for stream in streams:
stype = stream.get("Type", "")
if stype == "Video" and "video" not in result:
width = stream.get("Width", 0)
height = stream.get("Height", 0)
codec = (stream.get("Codec") or "").upper()
display = stream.get("DisplayTitle", "")
if height >= 2160:
res = "4K"
elif height >= 1080:
res = "1080p"
elif height >= 720:
res = "720p"
elif height > 0:
res = f"{height}p"
else:
res = None
result["video"] = {
"resolution": res,
"width": width,
"height": height,
"codec": codec,
"display": display,
"hdr": "HDR" in display or "DV" in display or "Dolby Vision" in display,
}
elif stype == "Audio" and "audio" not in result:
codec = (stream.get("Codec") or "").upper()
layout = stream.get("ChannelLayout", "")
display = stream.get("DisplayTitle", "")
if "atmos" in display.lower():
audio_label = "Atmos"
elif "truehd" in codec.lower():
audio_label = "TrueHD"
elif "dts" in codec.lower():
audio_label = "DTS-HD" if "hd" in display.lower() else "DTS"
elif "eac3" in codec.lower() or "e-ac-3" in codec.lower():
audio_label = "DD+"
elif "ac3" in codec.lower():
audio_label = "DD"
elif "aac" in codec.lower():
audio_label = "AAC"
elif "flac" in codec.lower():
audio_label = "FLAC"
else:
audio_label = codec
result["audio"] = {
"codec": codec,
"channels": layout,
"label": audio_label,
"display": display,
}
return result or None
def _get_jellyfin_coordinator(hass: HomeAssistant, jellyfin_entity_id: str) -> Any:
"""Resolve the Jellyfin integration's data-update coordinator for the
entity the user picked. Returns the coordinator, or None if the
entity is not registered / its config entry has no runtime_data yet."""
if not jellyfin_entity_id:
return None
registry = er.async_get(hass)
entity_entry = registry.async_get(jellyfin_entity_id)
if not entity_entry or not entity_entry.config_entry_id:
return None
config_entry = hass.config_entries.async_get_entry(entity_entry.config_entry_id)
if not config_entry:
return None
return getattr(config_entry, "runtime_data", None)
async def fetch_session_media_info(
hass: HomeAssistant,
jellyfin_entity_id: str,
device_name: str,
) -> dict | None:
"""Find stream info for the session whose DeviceName matches ``device_name``.
Reads from the already-authenticated Jellyfin coordinator — no new
HTTP calls, no credentials in this integration's config.
"""
coordinator = _get_jellyfin_coordinator(hass, jellyfin_entity_id)
if coordinator is None:
return None
sessions = getattr(coordinator, "data", None) or {}
device_lower = device_name.lower()
for sess in sessions.values():
sess_device = (sess.get("DeviceName") or "").lower()
if device_lower not in sess_device:
continue
item = sess.get("NowPlayingItem")
if not item:
continue
return _parse_streams(item)
return None
@@ -0,0 +1,12 @@
{
"domain": "nvidia_shield",
"name": "NVIDIA Shield",
"codeowners": ["@ltomes"],
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/ltomes/ha-nvidia-shield",
"issue_tracker": "https://github.com/ltomes/ha-nvidia-shield/issues",
"iot_class": "local_polling",
"requirements": ["adb-shell>=0.4.0"],
"version": "0.1.0"
}
@@ -0,0 +1,734 @@
"""Media player entity for Nvidia Shield.
State architecture:
- Power/availability: androidtv_remote (local push, fast)
- Playback state: Jellyfin (polling, slow 10-30s)
- Metadata: Jellyfin attributes
- ALL commands: androidtv_remote (Jellyfin is read-only)
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 to androidtv_remote → Shield pauses on screen
3. 10-30s later Jellyfin confirms paused → intent cleared
4. If Jellyfin doesn't confirm in 30s → retry once → if still no → surrender to truth
"""
import logging
import time
from .ha_adb_common import PlaybackIntent, running_apps_from_tasks
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_PLAYING, STATE_PAUSED, STATE_IDLE, STATE_OFF
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, Event
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
from .const import (
ADB_POLL_INTERVAL,
ADB_RETRY_INTERVAL,
ATTR_MEDIA_DAG_JELLYFIN_ENTITY,
ATTR_MEDIA_DAG_NOW_PLAYING,
ATTR_MEDIA_DAG_RECEIVER_ENTITY,
ATTR_MEDIA_DAG_RECEIVER_INPUT,
ATTR_MEDIA_DAG_ROLE,
ATTR_MEDIA_DAG_RUNNING_APPS,
CONF_ADB_ENABLED,
CONF_ADB_HOST,
CONF_ADB_PORT,
CONF_ADB_STORE_APP_INFO,
CONF_ANDROIDTV_ENTITY,
CONF_JELLYFIN_ENTITY,
CONF_POWER_ENTITY,
CONF_RECEIVER_ENTITY,
CONF_RECEIVER_INPUT,
DEFAULT_ADB_PORT,
DOMAIN,
)
from .adb_session import AdbMediaSessionPoller, ensure_adb_key
from .jellyfin import fetch_session_media_info
from .power import discover_power_siblings
_LOGGER = logging.getLogger(__name__)
INTENT_TIMEOUT = 10 # seconds before self-heal fires
MAX_RETRIES = 1
# Map service names to the MediaPlayerState we expect after the command
SERVICE_TO_STATE = {
"media_pause": MediaPlayerState.PAUSED,
"media_play": MediaPlayerState.PLAYING,
"media_stop": MediaPlayerState.IDLE,
}
_BASE_FEATURES = (
MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.VOLUME_MUTE
)
_PLAYBACK_FEATURES = (
MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.PREVIOUS_TRACK
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
async_add_entities([NvidiaShieldMediaPlayer(hass, entry)])
class NvidiaShieldMediaPlayer(MediaPlayerEntity):
_attr_device_class = MediaPlayerDeviceClass.TV
_attr_has_entity_name = True
_attr_name = None
_attr_should_poll = False
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
self.hass = hass
self._entry = entry
self._androidtv_entity = entry.data[CONF_ANDROIDTV_ENTITY]
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_media_player"
self._unsub_listeners: list = []
# Intent tracking
self._intent: PlaybackIntent | None = None
self._intent_timer_unsub: CALLBACK_TYPE | None = None
# Power monitoring — discovered from power switch entity
self._power_sensors: dict[str, str] = {}
# Jellyfin media stream info (resolution, codec, etc.)
self._media_info: dict | None = None
# ADB media session, wake state, and running apps
self._adb_poller: AdbMediaSessionPoller | None = None
self._adb_poll_unsub: CALLBACK_TYPE | None = None
self._adb_awake: bool | None = None
self._running_apps: list[dict] = []
@property
def _jellyfin_entity(self) -> str | None:
return self._entry.options.get(CONF_JELLYFIN_ENTITY) or None
@property
def _receiver_entity(self) -> str | None:
return self._entry.options.get(CONF_RECEIVER_ENTITY) or None
@property
def _receiver_input(self) -> str | None:
return self._entry.options.get(CONF_RECEIVER_INPUT) or None
@property
def device_info(self) -> DeviceInfo:
return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)},
name=self._entry.title,
manufacturer="NVIDIA",
model="Shield TV",
)
@property
def _power_entity(self) -> str | None:
return self._entry.options.get(CONF_POWER_ENTITY) or None
@property
def _adb_enabled(self) -> bool:
return self._entry.options.get(CONF_ADB_ENABLED, False)
@property
def _adb_store_info(self) -> bool:
return self._entry.options.get(CONF_ADB_STORE_APP_INFO, True)
async def async_added_to_hass(self) -> None:
# Discover power sensor siblings from the power switch
if self._power_entity:
self._power_sensors = discover_power_siblings(self.hass, self._power_entity)
# Set up ADB media session poller
if self._adb_enabled:
await self._setup_adb()
entities = [self._androidtv_entity]
if self._jellyfin_entity:
entities.append(self._jellyfin_entity)
if self._power_entity:
entities.append(self._power_entity)
self._unsub_listeners.append(
async_track_state_change_event(
self.hass, entities, self._on_source_change
)
)
async def async_will_remove_from_hass(self) -> None:
for unsub in self._unsub_listeners:
unsub()
self._unsub_listeners.clear()
self._clear_intent()
if self._adb_poll_unsub:
self._adb_poll_unsub()
self._adb_poll_unsub = None
if self._adb_poller:
await self._adb_poller.disconnect()
# ── Intent management ──
def _set_intent(self, service: str, target: MediaPlayerState) -> None:
"""Record a new playback intent. Replaces any existing intent."""
# Cancel existing timer
if self._intent_timer_unsub:
self._intent_timer_unsub()
# Capture current Jellyfin state before the intent
jf = self._read(self._jellyfin_entity)
prior = jf.state if jf else None
self._intent = PlaybackIntent.create(
target_state=target.value,
command=service,
prior_state=prior,
)
# Schedule self-heal timeout
self._intent_timer_unsub = async_call_later(
self.hass, INTENT_TIMEOUT, self._on_intent_timeout
)
_LOGGER.debug("Intent set: %s → %s (prior JF: %s)", service, target, prior)
def _clear_intent(self) -> None:
"""Clear the pending intent and its timer."""
if self._intent_timer_unsub:
self._intent_timer_unsub()
self._intent_timer_unsub = None
self._intent = None
@callback
def _on_intent_timeout(self, _now) -> None:
"""Self-heal: intent wasn't confirmed by Jellyfin in time."""
intent = self._intent
if intent is None:
return
jf = self._read(self._jellyfin_entity)
jf_state = jf.state if jf else None
# Check if Jellyfin caught up during the timeout window
if jf_state == intent.target_state:
_LOGGER.debug("Intent %s confirmed at timeout check", intent.command)
self._clear_intent()
self.async_write_ha_state()
return
# Retry once
if intent.retry_count < MAX_RETRIES:
intent.retry_count += 1
intent.created_at = time.monotonic()
_LOGGER.warning(
"Intent %s not confirmed (JF=%s, want=%s), retrying (attempt %d)",
intent.command, jf_state, intent.target_state, intent.retry_count,
)
self.hass.async_create_task(
self._cmd(intent.command)
)
self._intent_timer_unsub = async_call_later(
self.hass, INTENT_TIMEOUT, self._on_intent_timeout
)
return
# Exhausted retries — surrender to Jellyfin truth
_LOGGER.error(
"Intent %s failed after %d retries (JF=%s), falling back to truth",
intent.command, MAX_RETRIES, jf_state,
)
self._clear_intent()
self.async_write_ha_state()
# ── State change listener ──
@callback
def _on_source_change(self, event: Event) -> None:
"""React to state changes in androidtv or Jellyfin."""
entity_id = event.data.get("entity_id")
new_state_obj = event.data.get("new_state")
# Power off clears any intent — device is off, intent is moot
if entity_id in (self._androidtv_entity, self._power_entity) and new_state_obj:
if not self._is_power_on():
if self._intent:
_LOGGER.debug("Intent cleared: device powered off")
self._clear_intent()
# Jellyfin state change — check for intent confirmation and fetch media info
if entity_id == self._jellyfin_entity and new_state_obj:
new_jf = new_state_obj.state
# Intent confirmation
if self._intent:
intent = self._intent
if new_jf == intent.target_state:
_LOGGER.debug("Intent %s confirmed by Jellyfin", intent.command)
self._clear_intent()
elif new_jf not in (
intent.prior_state, "unavailable", "unknown", None
):
_LOGGER.debug(
"Intent %s overridden by external action (JF=%s)",
intent.command, new_jf,
)
self._clear_intent()
# Fetch media stream info when playing
if new_jf in (STATE_PLAYING, STATE_PAUSED):
entity_picture = new_state_obj.attributes.get("entity_picture", "")
self.hass.async_create_task(
self._fetch_media_info(entity_picture)
)
else:
self._media_info = None
# Always re-evaluate state
self.async_write_ha_state()
# ── Helpers ──
def _read(self, entity_id: str | None):
if not entity_id:
return None
return self.hass.states.get(entity_id)
def _discover_adb_host(self) -> str | None:
"""Auto-detect ADB host from the androidtv_remote config entry for our entity."""
from homeassistant.helpers import entity_registry as er
registry = er.async_get(self.hass)
entity_entry = registry.async_get(self._androidtv_entity)
if not entity_entry or not entity_entry.config_entry_id:
return None
config_entry = self.hass.config_entries.async_get_entry(entity_entry.config_entry_id)
if config_entry:
return config_entry.data.get("host")
return None
async def _setup_adb(self) -> None:
"""Initialize ADB connection and start polling."""
try:
# Only auto-connect if ADB was previously paired (key exists)
import os
key_dir = os.path.join(self.hass.config.config_dir, ".storage", DOMAIN)
key_file = os.path.join(key_dir, "adbkey")
if not await self.hass.async_add_executor_job(os.path.exists, key_file):
_LOGGER.info(
"ADB key not found — skipping auto-connect. "
"Use the Pair ADB button or reconfigure the integration."
)
return
# Get device IP: explicit config > auto-detect from androidtv_remote
host = self._entry.options.get(CONF_ADB_HOST)
port = self._entry.options.get(CONF_ADB_PORT, DEFAULT_ADB_PORT)
if not host:
host = self._discover_adb_host()
if not host:
_LOGGER.warning("Could not determine ADB host. Set it in integration options.")
return
key_path = key_file
self._adb_poller = AdbMediaSessionPoller(host, port, key_path)
if await self._adb_poller.connect():
# Start periodic polling
self._adb_poll_unsub = async_call_later(
self.hass, ADB_POLL_INTERVAL, self._adb_poll_callback
)
_LOGGER.info("ADB media session polling started for %s", host)
else:
_LOGGER.warning(
"ADB connection to %s failed. If this is the first connection, "
"approve the ADB debugging prompt on the device. "
"Retrying in %d seconds.",
host, ADB_RETRY_INTERVAL,
)
self._adb_poll_unsub = async_call_later(
self.hass, ADB_RETRY_INTERVAL, self._adb_poll_callback
)
except Exception:
_LOGGER.exception("Failed to set up ADB")
@callback
def _adb_poll_callback(self, _now) -> None:
"""Schedule ADB poll as a task."""
self.hass.async_create_task(self._adb_poll())
async def _adb_poll(self) -> None:
"""Poll ADB for wake state and media session, then schedule next poll."""
try:
if not self._adb_poller:
return
# Always check wake state
prev_awake = self._adb_awake
self._adb_awake = await self._adb_poller.is_awake()
# Only poll media session when awake and Jellyfin is NOT active
jf = self._read(self._jellyfin_entity)
jf_active = jf and jf.state in (STATE_PLAYING, STATE_PAUSED)
if self._adb_awake:
# Always poll ADB when awake — pass the foreground app
# so the parser prefers its session over stale ones
await self._adb_poller.poll(foreground_package=self.app_id)
self._running_apps = await self._adb_poller.poll_running_apps()
elif self._adb_poller.last_info:
# Clear media info when asleep
self._adb_poller.clear_info()
self._running_apps = []
# Always update state (wake state may have changed)
if self._adb_awake != prev_awake or self._adb_poller.last_info:
self.async_write_ha_state()
except Exception:
_LOGGER.debug("ADB poll error", exc_info=True)
finally:
# Use slow retry if ADB is disconnected, normal interval if connected
if self._adb_enabled:
interval = ADB_POLL_INTERVAL if self._adb_poller.connected else ADB_RETRY_INTERVAL
self._adb_poll_unsub = async_call_later(
self.hass, interval, self._adb_poll_callback
)
async def _fetch_media_info(self, entity_picture: str) -> None:
"""Read stream info from the HA Jellyfin integration's coordinator."""
info = await fetch_session_media_info(
self.hass, self._jellyfin_entity, self._entry.title
)
if info != self._media_info:
self._media_info = info
self.async_write_ha_state()
def _jf_active(self) -> bool:
"""Is Jellyfin reporting active playback?"""
jf = self._read(self._jellyfin_entity)
if jf and jf.state in (STATE_PLAYING, STATE_PAUSED):
return True
# If we have an active intent for play/pause, media is conceptually active
if self._intent and self._intent.target_state in (
STATE_PLAYING, STATE_PAUSED
):
return True
return False
# ── State ──
def _is_power_on(self) -> bool | None:
"""Determine if the Shield is powered on.
Priority: ADB wake state > power entity plug off > unknown.
ADB is the authority — it knows if the device is awake or asleep.
The plug being "on" just means the outlet has power (standby).
"""
# 1. ADB wake state — most accurate
if self._adb_awake is not None:
return self._adb_awake
# 2. Power entity — only trust "off" (plug pulled = definitely off)
if self._power_entity:
power_state = self._read(self._power_entity)
if power_state and power_state.state == "off":
return False
return None
@property
def state(self) -> MediaPlayerState:
# Check if plug is off first — truly off
if self._power_entity:
power_state = self._read(self._power_entity)
if power_state and power_state.state == "off":
return MediaPlayerState.OFF
power = self._is_power_on()
if power is False:
return MediaPlayerState.IDLE # ADB says asleep — standby (yellow dot)
if power is None:
return MediaPlayerState.IDLE
# Tier 1: Active intent overrides Jellyfin
if self._intent is not None:
return MediaPlayerState(self._intent.target_state)
# Tier 2: Jellyfin is authoritative
jf = self._read(self._jellyfin_entity)
if jf:
if jf.state == STATE_PLAYING:
return MediaPlayerState.PLAYING
if jf.state == STATE_PAUSED:
return MediaPlayerState.PAUSED
# Tier 3: ADB media session (non-Jellyfin apps)
if self._adb_poller and self._adb_poller.last_info:
adb_info = self._adb_poller.last_info
if adb_info.playing:
return MediaPlayerState.PLAYING
if adb_info.paused:
return MediaPlayerState.PAUSED
# Tier 4: On but no media
return MediaPlayerState.ON
@property
def supported_features(self) -> MediaPlayerEntityFeature:
if self._jf_active():
return _BASE_FEATURES | _PLAYBACK_FEATURES
# ADB media session also supports playback controls
if self._adb_poller and self._adb_poller.last_info:
adb_info = self._adb_poller.last_info
if adb_info.playing or adb_info.paused:
return _BASE_FEATURES | _PLAYBACK_FEATURES
return _BASE_FEATURES
# ── Metadata from Jellyfin ──
def _jf_attr(self, key: str):
jf = self._read(self._jellyfin_entity)
return jf.attributes.get(key) if jf else None
@property
def app_id(self):
atv = self._read(self._androidtv_entity)
return atv.attributes.get("app_id") if atv else None
@property
def app_name(self):
atv = self._read(self._androidtv_entity)
return atv.attributes.get("app_name") if atv else None
@property
def media_content_type(self):
return self._jf_attr("media_content_type") if self._jf_active() else None
@property
def media_title(self):
return self._jf_attr("media_title") if self._jf_active() else None
@property
def media_series_title(self):
return self._jf_attr("media_series_title") if self._jf_active() else None
@property
def media_season(self):
return self._jf_attr("media_season") if self._jf_active() else None
@property
def media_episode(self):
return self._jf_attr("media_episode") if self._jf_active() else None
@property
def media_duration(self):
return self._jf_attr("media_duration") if self._jf_active() else None
@property
def media_position(self):
return self._jf_attr("media_position") if self._jf_active() else None
@property
def media_position_updated_at(self):
return self._jf_attr("media_position_updated_at") if self._jf_active() else None
@property
def media_image_url(self):
return self._jf_attr("entity_picture") if self._jf_active() else None
def _get_now_playing(self) -> dict | None:
if not self._jf_active():
return None
jf = self._read(self._jellyfin_entity)
if not jf:
return None
attrs = jf.attributes
np = {"state": self.state.value, "title": attrs.get("media_title"),
"media_type": attrs.get("media_content_type")}
for src, dst in [("media_series_title", "series"), ("media_season", "season"),
("media_episode", "episode"), ("media_duration", "duration"),
("media_position", "position")]:
v = attrs.get(src)
if v is not None:
np[dst] = v
if attrs.get("entity_picture"):
np["thumbnail"] = attrs["entity_picture"]
if self._media_info:
np["media_info"] = self._media_info
return np
def _get_adb_now_playing(self) -> dict | None:
"""Build now-playing from ADB media session (non-Jellyfin apps)."""
if not self._adb_poller or not self._adb_poller.last_info:
return None
if not self._adb_store_info:
# Privacy mode: show that something is playing but not what
info = self._adb_poller.last_info
if info.playing or info.paused:
return {
"state": "playing" if info.playing else "paused",
"title": self.app_name or self.app_id or "Playing",
"source": "media_session",
}
return None
info = self._adb_poller.last_info
if not info.title:
return None
np = {
"state": "playing" if info.playing else ("paused" if info.paused else "idle"),
"title": info.title,
"source": "media_session",
}
if info.artist:
np["artist"] = info.artist
if info.album:
np["album"] = info.album
return np
def _get_power_data(self) -> dict | None:
"""Read current values from discovered power sensors."""
if not self._power_sensors:
return None
data = {}
for key, entity_id in self._power_sensors.items():
if key == "switch":
continue
state = self._read(entity_id)
if state and state.state not in ("unavailable", "unknown"):
try:
data[key] = float(state.state)
except (ValueError, TypeError):
data[key] = state.state
return data if data else None
def _get_running_apps(self) -> list[dict] | None:
"""Build a list of running apps from ADB recent tasks + media sessions."""
if not self._adb_poller or not self._running_apps:
return None
if not self._adb_store_info:
return None
return running_apps_from_tasks(
self._running_apps,
self._adb_poller.all_sessions,
foreground_package=self.app_id,
)
@property
def extra_state_attributes(self) -> dict:
# Now playing: prefer Jellyfin, fall back to ADB media session
now_playing = self._get_now_playing()
if not now_playing:
now_playing = self._get_adb_now_playing()
attrs = {"app_id": self.app_id, "app_name": self.app_name,
ATTR_MEDIA_DAG_ROLE: "source",
ATTR_MEDIA_DAG_NOW_PLAYING: now_playing}
if self._receiver_entity:
attrs[ATTR_MEDIA_DAG_RECEIVER_ENTITY] = self._receiver_entity
if self._receiver_input:
attrs[ATTR_MEDIA_DAG_RECEIVER_INPUT] = self._receiver_input
if self._jellyfin_entity:
attrs[ATTR_MEDIA_DAG_JELLYFIN_ENTITY] = self._jellyfin_entity
# Running apps from ADB media sessions
running_apps = self._get_running_apps()
if running_apps:
attrs[ATTR_MEDIA_DAG_RUNNING_APPS] = running_apps
# Power monitoring
if self._power_entity:
attrs["power_switch"] = self._power_entity
power_data = self._get_power_data()
if power_data:
attrs["power_monitoring"] = power_data
# Intent state for debugging
if self._intent:
attrs["_intent"] = self._intent.to_debug_dict()
return attrs
# ── Commands — ALL go to androidtv_remote ──
async def _cmd(self, service: str, **extra) -> None:
data = {"entity_id": self._androidtv_entity, **extra}
await self.hass.services.async_call("media_player", service, data)
async def async_turn_on(self):
await self._cmd("turn_on")
async def async_turn_off(self):
await self._cmd("turn_off")
async def async_volume_up(self):
await self._cmd("volume_up")
async def async_volume_down(self):
await self._cmd("volume_down")
async def async_mute_volume(self, mute):
await self._cmd("volume_mute", is_volume_muted=mute)
async def _media_cmd(self, ha_service: str, adb_command: str, intent_state: MediaPlayerState | None = None) -> None:
"""Send a media command. Uses ADB dispatch for non-Jellyfin apps when available."""
if intent_state:
self._set_intent(ha_service, intent_state)
self.async_write_ha_state()
# Use ADB media dispatch when ADB is active and Jellyfin isn't playing
jf = self._read(self._jellyfin_entity)
jf_active = jf and jf.state in (STATE_PLAYING, STATE_PAUSED)
if self._adb_poller and self._adb_poller.connected and not jf_active:
success = await self._adb_poller.send_media_command(adb_command)
if success:
return
# Fall back to Android TV Remote
await self._cmd(ha_service)
async def async_media_play(self):
await self._media_cmd("media_play", "play", MediaPlayerState.PLAYING)
async def async_media_pause(self):
await self._media_cmd("media_pause", "pause", MediaPlayerState.PAUSED)
async def async_media_stop(self):
await self._media_cmd("media_stop", "stop", MediaPlayerState.IDLE)
async def async_media_next_track(self):
await self._media_cmd("media_next_track", "next")
async def async_media_previous_track(self):
await self._media_cmd("media_previous_track", "previous")
async def async_bring_to_foreground(self, package: str) -> bool:
"""Bring a specific app to the foreground via ADB."""
if not self._adb_poller:
_LOGGER.warning("ADB not available for bring_to_foreground")
return False
return await self._adb_poller.bring_to_foreground(package)
+60
View File
@@ -0,0 +1,60 @@
"""Power monitoring helpers — discover sibling sensors from a power switch entity."""
import logging
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
_LOGGER = logging.getLogger(__name__)
# Map unit_of_measurement to a friendly key
_UNIT_TO_KEY = {
"W": "power_w",
"kWh": "energy_kwh",
"V": "voltage_v",
"A": "current_a",
}
def discover_power_siblings(
hass: HomeAssistant, power_entity_id: str
) -> dict[str, str]:
"""Given a power switch entity_id, find sibling sensor entities on the same device.
Returns a dict like:
{
"switch": "switch.wall_plug_switch",
"power_w": "sensor.wall_plug_switch_electric_consumption_w",
"energy_kwh": "sensor.wall_plug_switch_electric_consumption_kwh",
"voltage_v": "sensor.wall_plug_switch_electric_consumption_v",
"current_a": "sensor.wall_plug_switch_electric_consumption_a",
}
"""
registry = er.async_get(hass)
result = {"switch": power_entity_id}
# Find the device_id for the power entity
power_entry = registry.async_get(power_entity_id)
if not power_entry or not power_entry.device_id:
_LOGGER.debug("Power entity %s has no device_id", power_entity_id)
return result
device_id = power_entry.device_id
# Find all sensor siblings on the same device
for entry in registry.entities.values():
if entry.device_id != device_id:
continue
if entry.domain != "sensor":
continue
if entry.disabled_by is not None:
continue
unit = entry.unit_of_measurement
if unit in _UNIT_TO_KEY:
key = _UNIT_TO_KEY[unit]
if key not in result: # first match wins
result[key] = entry.entity_id
_LOGGER.debug("Power siblings for %s: %s", power_entity_id, result)
return result
+166
View File
@@ -0,0 +1,166 @@
"""Sensor entities for Nvidia Shield."""
import logging
from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_PLAYING, STATE_PAUSED
from homeassistant.core import HomeAssistant, callback, Event
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event
from .const import (
CONF_ANDROIDTV_ENTITY,
CONF_JELLYFIN_ENTITY,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Nvidia Shield sensor entities."""
entities = [NvidiaShieldCurrentAppSensor(hass, entry)]
if entry.options.get(CONF_JELLYFIN_ENTITY):
entities.append(NvidiaShieldNowPlayingSensor(hass, entry))
async_add_entities(entities)
class NvidiaShieldSensorBase(SensorEntity):
"""Base class for Shield sensors."""
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
self.hass = hass
self._entry = entry
self._unsub_listeners: list = []
@property
def device_info(self) -> DeviceInfo:
return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)},
name=self._entry.title,
manufacturer="NVIDIA",
model="Shield TV",
)
async def async_will_remove_from_hass(self) -> None:
for unsub in self._unsub_listeners:
unsub()
self._unsub_listeners.clear()
class NvidiaShieldCurrentAppSensor(NvidiaShieldSensorBase):
"""Current running app on the Shield."""
_attr_name = "Current App"
_attr_icon = "mdi:application"
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
super().__init__(hass, entry)
self._androidtv_entity = entry.data[CONF_ANDROIDTV_ENTITY]
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_current_app"
async def async_added_to_hass(self) -> None:
self._unsub_listeners.append(
async_track_state_change_event(
self.hass, [self._androidtv_entity], self._handle_state_change
)
)
@callback
def _handle_state_change(self, event: Event) -> None:
self.async_write_ha_state()
@property
def native_value(self) -> str | None:
state = self.hass.states.get(self._androidtv_entity)
if not state:
return None
return state.attributes.get("app_name") or state.attributes.get("app_id")
@property
def extra_state_attributes(self) -> dict:
state = self.hass.states.get(self._androidtv_entity)
if not state:
return {}
return {
"app_id": state.attributes.get("app_id"),
"app_name": state.attributes.get("app_name"),
}
class NvidiaShieldNowPlayingSensor(NvidiaShieldSensorBase):
"""What's currently playing via Jellyfin."""
_attr_name = "Now Playing"
_attr_icon = "mdi:play-circle"
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
super().__init__(hass, entry)
self._jellyfin_entity = entry.options[CONF_JELLYFIN_ENTITY]
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_now_playing"
async def async_added_to_hass(self) -> None:
self._unsub_listeners.append(
async_track_state_change_event(
self.hass, [self._jellyfin_entity], self._handle_state_change
)
)
@callback
def _handle_state_change(self, event: Event) -> None:
self.async_write_ha_state()
@property
def native_value(self) -> str | None:
state = self.hass.states.get(self._jellyfin_entity)
if not state or state.state not in (STATE_PLAYING, STATE_PAUSED):
return "Idle"
attrs = state.attributes
title = attrs.get("media_title", "")
series = attrs.get("media_series_title")
if series:
season = attrs.get("media_season", "")
episode = attrs.get("media_episode", "")
return f"{series} S{season}E{episode} - {title}"
return title or "Playing"
@property
def extra_state_attributes(self) -> dict:
state = self.hass.states.get(self._jellyfin_entity)
if not state or state.state not in (STATE_PLAYING, STATE_PAUSED):
return {"playback_state": "idle"}
attrs = state.attributes
result = {
"playback_state": state.state,
"title": attrs.get("media_title"),
"media_type": attrs.get("media_content_type"),
}
if attrs.get("media_series_title"):
result["series"] = attrs["media_series_title"]
if attrs.get("media_season") is not None:
result["season"] = attrs["media_season"]
if attrs.get("media_episode") is not None:
result["episode"] = attrs["media_episode"]
if attrs.get("media_duration") is not None:
result["duration"] = attrs["media_duration"]
if attrs.get("media_position") is not None:
result["position"] = attrs["media_position"]
if attrs.get("entity_picture"):
result["thumbnail"] = attrs["entity_picture"]
return result
@@ -0,0 +1,72 @@
{
"config": {
"step": {
"user": {
"title": "NVIDIA Shield",
"description": "Set up your NVIDIA Shield. Link the Android TV remote, Jellyfin, receiver, and power monitoring.",
"data": {
"name": "Device Name",
"androidtv_entity": "Android TV Remote",
"jellyfin_entity": "Jellyfin Media Player",
"receiver_entity": "Receiver",
"receiver_input": "Receiver Input",
"power_entity": "Power Switch",
"adb_enabled": "Enable ADB Media Session",
"adb_store_app_info": "Show App Media Info",
"adb_host": "ADB Host",
"adb_port": "ADB Port"
},
"data_description": {
"androidtv_entity": "The media_player entity from the Android TV Remote integration.",
"jellyfin_entity": "The Jellyfin media_player for this device. Leave blank to auto-detect.",
"receiver_entity": "The receiver this device is connected to.",
"receiver_input": "Which input on the receiver (e.g. 'Shield').",
"power_entity": "Smart plug switch for reliable on/off detection.",
"adb_enabled": "Use ADB to read what's playing from any app (NewPipe, Spotify, etc.). Requires ADB debugging enabled on the Shield.",
"adb_store_app_info": "Show title and artist from non-Jellyfin apps. Disable to only show the app name (privacy mode).",
"adb_host": "Override the ADB host IP. Leave blank to auto-detect from the Android TV entity.",
"adb_port": "ADB port number. Default is 5555."
}
},
"adb_pair": {
"title": "Pair ADB",
"description": "On the Shield, go to Settings > Developer options and enable Network debugging. Then click Submit. You will see a prompt on the Shield to allow the connection -- approve it. If the connection fails, click Submit to try again."
}
},
"error": {
"adb_failed": "Could not connect via ADB. Make sure Network debugging is enabled on the Shield and you approved the connection prompt. Click Submit to try again.",
"adb_no_host": "Could not determine the Shield's IP address. Set the ADB Host field on the previous step."
},
"abort": {
"already_configured": "This Shield entity is already configured."
}
},
"options": {
"step": {
"init": {
"title": "NVIDIA Shield Settings",
"description": "Configure linked devices and monitoring.",
"data": {
"jellyfin_entity": "Jellyfin Media Player",
"receiver_entity": "Receiver",
"receiver_input": "Receiver Input",
"power_entity": "Power Switch",
"adb_enabled": "Enable ADB Media Session",
"adb_store_app_info": "Show App Media Info",
"adb_host": "ADB Host",
"adb_port": "ADB Port"
},
"data_description": {
"jellyfin_entity": "The Jellyfin media_player for this device. Session stream info is read from the HA Jellyfin integration's existing coordinator — no extra config needed.",
"receiver_entity": "The receiver this device is connected to.",
"receiver_input": "Which input on the receiver.",
"power_entity": "Smart plug switch for reliable on/off detection.",
"adb_enabled": "Use ADB to read now-playing from any app. Requires ADB debugging on the Shield.",
"adb_store_app_info": "Show title and artist from non-Jellyfin apps. Disable for privacy.",
"adb_host": "IP address for ADB connection. Leave blank to auto-detect from the Android TV Remote integration.",
"adb_port": "ADB port (default 5555)."
}
}
}
}
}
@@ -0,0 +1,72 @@
{
"config": {
"step": {
"user": {
"title": "NVIDIA Shield",
"description": "Set up your NVIDIA Shield. Link the Android TV remote, Jellyfin, receiver, and power monitoring.",
"data": {
"name": "Device Name",
"androidtv_entity": "Android TV Remote",
"jellyfin_entity": "Jellyfin Media Player",
"receiver_entity": "Receiver",
"receiver_input": "Receiver Input",
"power_entity": "Power Switch",
"adb_enabled": "Enable ADB Media Session",
"adb_store_app_info": "Show App Media Info",
"adb_host": "ADB Host",
"adb_port": "ADB Port"
},
"data_description": {
"androidtv_entity": "The media_player entity from the Android TV Remote integration.",
"jellyfin_entity": "The Jellyfin media_player for this device. Leave blank to auto-detect.",
"receiver_entity": "The receiver this device is connected to.",
"receiver_input": "Which input on the receiver (e.g. 'Shield').",
"power_entity": "Smart plug switch for reliable on/off detection.",
"adb_enabled": "Use ADB to read what's playing from any app (NewPipe, Spotify, etc.). Requires ADB debugging enabled on the Shield.",
"adb_store_app_info": "Show title and artist from non-Jellyfin apps. Disable to only show the app name (privacy mode).",
"adb_host": "Override the ADB host IP. Leave blank to auto-detect from the Android TV entity.",
"adb_port": "ADB port number. Default is 5555."
}
},
"adb_pair": {
"title": "Pair ADB",
"description": "On the Shield, go to Settings > Developer options and enable Network debugging. Then click Submit. You will see a prompt on the Shield to allow the connection -- approve it. If the connection fails, click Submit to try again."
}
},
"error": {
"adb_failed": "Could not connect via ADB. Make sure Network debugging is enabled on the Shield and you approved the connection prompt. Click Submit to try again.",
"adb_no_host": "Could not determine the Shield's IP address. Set the ADB Host field on the previous step."
},
"abort": {
"already_configured": "This Shield entity is already configured."
}
},
"options": {
"step": {
"init": {
"title": "NVIDIA Shield Settings",
"description": "Configure linked devices and monitoring.",
"data": {
"jellyfin_entity": "Jellyfin Media Player",
"receiver_entity": "Receiver",
"receiver_input": "Receiver Input",
"power_entity": "Power Switch",
"adb_enabled": "Enable ADB Media Session",
"adb_store_app_info": "Show App Media Info",
"adb_host": "ADB Host",
"adb_port": "ADB Port"
},
"data_description": {
"jellyfin_entity": "The Jellyfin media_player for this device. Session stream info is read from the HA Jellyfin integration's existing coordinator — no extra config needed.",
"receiver_entity": "The receiver this device is connected to.",
"receiver_input": "Which input on the receiver.",
"power_entity": "Smart plug switch for reliable on/off detection.",
"adb_enabled": "Use ADB to read now-playing from any app. Requires ADB debugging on the Shield.",
"adb_store_app_info": "Show title and artist from non-Jellyfin apps. Disable for privacy.",
"adb_host": "IP address for ADB connection. Leave blank to auto-detect from the Android TV Remote integration.",
"adb_port": "ADB port (default 5555)."
}
}
}
}
}