From 0ad51879aee2b02c2968c0a0fc939b0df6dfa76a Mon Sep 17 00:00:00 2001 From: Levi Date: Fri, 17 Apr 2026 17:04:30 -0500 Subject: [PATCH] Initial release v0.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 19 + LICENSE | 21 + README.md | 99 +++ custom_components/nvidia_shield/__init__.py | 40 + .../nvidia_shield/adb_session.py | 37 + custom_components/nvidia_shield/button.py | 102 +++ .../nvidia_shield/config_flow.py | 327 ++++++++ custom_components/nvidia_shield/const.py | 38 + .../nvidia_shield/ha_adb_common/__init__.py | 32 + .../nvidia_shield/ha_adb_common/adb_client.py | 213 +++++ .../nvidia_shield/ha_adb_common/const.py | 25 + .../nvidia_shield/ha_adb_common/intent.py | 48 ++ .../nvidia_shield/ha_adb_common/keys.py | 56 ++ .../ha_adb_common/media_session.py | 122 +++ .../ha_adb_common/running_apps.py | 72 ++ custom_components/nvidia_shield/jellyfin.py | 133 ++++ custom_components/nvidia_shield/manifest.json | 12 + .../nvidia_shield/media_player.py | 734 ++++++++++++++++++ custom_components/nvidia_shield/power.py | 60 ++ custom_components/nvidia_shield/sensor.py | 166 ++++ custom_components/nvidia_shield/strings.json | 72 ++ .../nvidia_shield/translations/en.json | 72 ++ hacs.json | 4 + pyproject.toml | 34 + 24 files changed, 2538 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 custom_components/nvidia_shield/__init__.py create mode 100644 custom_components/nvidia_shield/adb_session.py create mode 100644 custom_components/nvidia_shield/button.py create mode 100644 custom_components/nvidia_shield/config_flow.py create mode 100644 custom_components/nvidia_shield/const.py create mode 100644 custom_components/nvidia_shield/ha_adb_common/__init__.py create mode 100644 custom_components/nvidia_shield/ha_adb_common/adb_client.py create mode 100644 custom_components/nvidia_shield/ha_adb_common/const.py create mode 100644 custom_components/nvidia_shield/ha_adb_common/intent.py create mode 100644 custom_components/nvidia_shield/ha_adb_common/keys.py create mode 100644 custom_components/nvidia_shield/ha_adb_common/media_session.py create mode 100644 custom_components/nvidia_shield/ha_adb_common/running_apps.py create mode 100644 custom_components/nvidia_shield/jellyfin.py create mode 100644 custom_components/nvidia_shield/manifest.json create mode 100644 custom_components/nvidia_shield/media_player.py create mode 100644 custom_components/nvidia_shield/power.py create mode 100644 custom_components/nvidia_shield/sensor.py create mode 100644 custom_components/nvidia_shield/strings.json create mode 100644 custom_components/nvidia_shield/translations/en.json create mode 100644 hacs.json create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..29c21f7 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..eee8b54 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a9f146f --- /dev/null +++ b/README.md @@ -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 diff --git a/custom_components/nvidia_shield/__init__.py b/custom_components/nvidia_shield/__init__.py new file mode 100644 index 0000000..86c0f62 --- /dev/null +++ b/custom_components/nvidia_shield/__init__.py @@ -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) diff --git a/custom_components/nvidia_shield/adb_session.py b/custom_components/nvidia_shield/adb_session.py new file mode 100644 index 0000000..23760ef --- /dev/null +++ b/custom_components/nvidia_shield/adb_session.py @@ -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) diff --git a/custom_components/nvidia_shield/button.py b/custom_components/nvidia_shield/button.py new file mode 100644 index 0000000..ab6c3b2 --- /dev/null +++ b/custom_components/nvidia_shield/button.py @@ -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() diff --git a/custom_components/nvidia_shield/config_flow.py b/custom_components/nvidia_shield/config_flow.py new file mode 100644 index 0000000..b1bff1f --- /dev/null +++ b/custom_components/nvidia_shield/config_flow.py @@ -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), + ) diff --git a/custom_components/nvidia_shield/const.py b/custom_components/nvidia_shield/const.py new file mode 100644 index 0000000..ae79490 --- /dev/null +++ b/custom_components/nvidia_shield/const.py @@ -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", +] diff --git a/custom_components/nvidia_shield/ha_adb_common/__init__.py b/custom_components/nvidia_shield/ha_adb_common/__init__.py new file mode 100644 index 0000000..403b779 --- /dev/null +++ b/custom_components/nvidia_shield/ha_adb_common/__init__.py @@ -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", +] diff --git a/custom_components/nvidia_shield/ha_adb_common/adb_client.py b/custom_components/nvidia_shield/ha_adb_common/adb_client.py new file mode 100644 index 0000000..fee5024 --- /dev/null +++ b/custom_components/nvidia_shield/ha_adb_common/adb_client.py @@ -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 diff --git a/custom_components/nvidia_shield/ha_adb_common/const.py b/custom_components/nvidia_shield/ha_adb_common/const.py new file mode 100644 index 0000000..79df930 --- /dev/null +++ b/custom_components/nvidia_shield/ha_adb_common/const.py @@ -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" diff --git a/custom_components/nvidia_shield/ha_adb_common/intent.py b/custom_components/nvidia_shield/ha_adb_common/intent.py new file mode 100644 index 0000000..2b324db --- /dev/null +++ b/custom_components/nvidia_shield/ha_adb_common/intent.py @@ -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, + } diff --git a/custom_components/nvidia_shield/ha_adb_common/keys.py b/custom_components/nvidia_shield/ha_adb_common/keys.py new file mode 100644 index 0000000..c307b2f --- /dev/null +++ b/custom_components/nvidia_shield/ha_adb_common/keys.py @@ -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 diff --git a/custom_components/nvidia_shield/ha_adb_common/media_session.py b/custom_components/nvidia_shield/ha_adb_common/media_session.py new file mode 100644 index 0000000..e700af4 --- /dev/null +++ b/custom_components/nvidia_shield/ha_adb_common/media_session.py @@ -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] diff --git a/custom_components/nvidia_shield/ha_adb_common/running_apps.py b/custom_components/nvidia_shield/ha_adb_common/running_apps.py new file mode 100644 index 0000000..48ec272 --- /dev/null +++ b/custom_components/nvidia_shield/ha_adb_common/running_apps.py @@ -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) diff --git a/custom_components/nvidia_shield/jellyfin.py b/custom_components/nvidia_shield/jellyfin.py new file mode 100644 index 0000000..774f4ae --- /dev/null +++ b/custom_components/nvidia_shield/jellyfin.py @@ -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 diff --git a/custom_components/nvidia_shield/manifest.json b/custom_components/nvidia_shield/manifest.json new file mode 100644 index 0000000..2c65879 --- /dev/null +++ b/custom_components/nvidia_shield/manifest.json @@ -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" +} diff --git a/custom_components/nvidia_shield/media_player.py b/custom_components/nvidia_shield/media_player.py new file mode 100644 index 0000000..54a38cb --- /dev/null +++ b/custom_components/nvidia_shield/media_player.py @@ -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) diff --git a/custom_components/nvidia_shield/power.py b/custom_components/nvidia_shield/power.py new file mode 100644 index 0000000..83121af --- /dev/null +++ b/custom_components/nvidia_shield/power.py @@ -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 diff --git a/custom_components/nvidia_shield/sensor.py b/custom_components/nvidia_shield/sensor.py new file mode 100644 index 0000000..37d1f88 --- /dev/null +++ b/custom_components/nvidia_shield/sensor.py @@ -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 diff --git a/custom_components/nvidia_shield/strings.json b/custom_components/nvidia_shield/strings.json new file mode 100644 index 0000000..64b1494 --- /dev/null +++ b/custom_components/nvidia_shield/strings.json @@ -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)." + } + } + } + } +} diff --git a/custom_components/nvidia_shield/translations/en.json b/custom_components/nvidia_shield/translations/en.json new file mode 100644 index 0000000..64b1494 --- /dev/null +++ b/custom_components/nvidia_shield/translations/en.json @@ -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)." + } + } + } + } +} diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..2b50797 --- /dev/null +++ b/hacs.json @@ -0,0 +1,4 @@ +{ + "name": "NVIDIA Shield", + "render_readme": true +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..27e8614 --- /dev/null +++ b/pyproject.toml @@ -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"