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

+19
View File
@@ -0,0 +1,19 @@
# Claude Code internal notes
CLAUDE.md
# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
# Tooling caches
.ruff_cache/
.pytest_cache/
.mypy_cache/
# Editor / OS
.DS_Store
.vscode/
.idea/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024-2026 NVIDIA Shield Integration Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+99
View File
@@ -0,0 +1,99 @@
# NVIDIA Shield
[![hacs][hacs-badge]][hacs-url] [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
Enhanced NVIDIA Shield TV integration for Home Assistant. ADB-based media session tracking, Jellyfin rich metadata, power monitoring, and receiver coordination.
```mermaid
graph TD
subgraph "NVIDIA Shield Integration"
ADB["ADB Media Session"] -->|now playing| Entity["media_player.nvidia_shield"]
JF["Jellyfin"] -->|rich metadata| Entity
ATV["Android TV Remote"] -->|power / commands| Entity
PWR["Smart Plug"] -->|power state| Entity
end
Entity -->|"media_dag_*"| MZ["Media Zone"]
style Entity fill:#76b900,color:#fff
style MZ fill:#1a73e8,color:#fff
style ADB fill:#444,color:#fff
style JF fill:#00a4dc,color:#fff
style ATV fill:#444,color:#fff
style PWR fill:#444,color:#fff
```
## Features
- **ADB Media Session** -- Now-playing from any app (PipePipe, Spotify, VLC, Plex, etc.), not just Jellyfin
- **Jellyfin Rich Metadata** -- Title, series, season/episode, duration, position, stream details (resolution, codec, HDR, audio format)
- **Power Monitoring** -- Smart plug for reliable on/off detection with auto-discovered sibling sensors (watts, voltage, current)
- **Receiver Coordination** -- Links to a receiver entity and input name
- **Intent Tracking** -- Commands reflect in HA state immediately, self-heal against actual device state
- **Privacy Mode** -- Show only app name without media details for non-Jellyfin apps
- **Controlled ADB Pairing** -- Dedicated pairing step in config flow + re-pair button entity
- **Media DAG Attributes** -- Full signal chain data for [Media Zone](https://github.com/ltomes/ha-media-zone) orchestration
## Installation
### HACS (recommended)
1. Open HACS > Integrations > three-dot menu > Custom repositories
2. Add `https://github.com/ltomes/ha-nvidia-shield` as an Integration
3. Search for "NVIDIA Shield" and install
4. Restart Home Assistant
### Manual
Copy `custom_components/nvidia_shield` to your HA `config/custom_components/` directory and restart.
## Configuration
1. Settings > Devices & Services > Add Integration > "NVIDIA Shield"
2. Select the Android TV Remote entity for your Shield
3. Enable ADB if you want now-playing from all apps
4. If ADB is enabled, the next step will pair -- approve the prompt on the Shield
5. Optionally configure Jellyfin, receiver, power switch
### ADB Pairing
ADB is paired during setup via a dedicated config flow step. If you need to re-pair later (e.g., after factory reset), use the **"Pair ADB"** button on the device page in HA.
Requirements: Settings > Developer options > Network debugging enabled on the Shield.
### Options
- **Jellyfin Media Player** -- Auto-detected or manually selected
- **Receiver + Input** -- For Media Zone coordination
- **Power Switch** -- Smart plug entity
- **ADB Host/Port** -- Override auto-detection if needed
## Entities
| Entity | Type | Description |
|--------|------|-------------|
| Media Player | `media_player` | Main entity with playback controls and metadata |
| Current App | `sensor` | Currently running app |
| Now Playing | `sensor` | Formatted now-playing string |
| Pair ADB | `button` | Initiate or re-pair ADB connection |
## Development
Shared ADB helpers live in [ha-adb-common](https://github.com/ltomes/ha-adb-common) and are also used by [ha-hisense-tv](https://github.com/ltomes/ha-hisense-tv). For HACS installs, a copy is **vendored** at `custom_components/nvidia_shield/ha_adb_common/` so end users need nothing extra. We plan to publish `ha-adb-common` to PyPI so it can be declared as a normal `manifest.json` requirement; until then, the vendored copy is the source consumed at runtime.
To hack on the shared code locally, clone [ha-adb-common](https://github.com/ltomes/ha-adb-common) as a sibling directory and run `uv sync --extra dev` — the `[tool.uv.sources]` section in `pyproject.toml` wires it up editable.
## Part of the Media Zone Ecosystem
This integration works standalone, but pairs with [ha-media-zone](https://github.com/ltomes/ha-media-zone) to create a unified zone controller:
- [ha-media-zone](https://github.com/ltomes/ha-media-zone) -- Orchestrates receiver + display + sources into one entity
- [ha-emotiva-mc1](https://github.com/ltomes/ha-emotiva-mc1) -- RS232 control for Emotiva MC1 receiver
- [ha-hisense-tv](https://github.com/ltomes/ha-hisense-tv) -- Hisense Google TV control via ADB + AirPlay
[hacs-badge]: https://img.shields.io/badge/HACS-Custom-41BDF5.svg
[hacs-url]: https://github.com/hacs/integration
## License
MIT
@@ -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)."
}
}
}
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"name": "NVIDIA Shield",
"render_readme": true
}
+34
View File
@@ -0,0 +1,34 @@
[project]
name = "ha-nvidia-shield"
version = "0.1.0"
description = "Home Assistant custom integration for NVIDIA Shield TV via ADB + Android TV Remote"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"homeassistant",
"adb-shell>=0.4.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
"ruff>=0.4",
# Shared ADB helpers. Vendored into custom_components/nvidia_shield/ha_adb_common/
# for HACS installs; used editable from a sibling checkout for development.
# Planned migration to a proper PyPI package.
"ha-adb-common",
]
[tool.uv.sources]
ha-adb-common = { path = "../ha-adb-common", editable = true }
[tool.ruff]
target-version = "py312"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "W", "I", "N", "UP", "B", "A", "SIM"]
[tool.pytest.ini_options]
asyncio_mode = "auto"